Skip to content

Re-queue worker pods on transient 502/503/504 from the Kubernetes API#68995

Closed
1fanwang wants to merge 7 commits into
apache:mainfrom
1fanwang:k8s-executor-requeue-transient-5xx
Closed

Re-queue worker pods on transient 502/503/504 from the Kubernetes API#68995
1fanwang wants to merge 7 commits into
apache:mainfrom
1fanwang:k8s-executor-requeue-transient-5xx

Conversation

@1fanwang

Copy link
Copy Markdown
Contributor

Stacked on #68480. This PR's own change is the last commit (52ea143705); the
earlier commits belong to #68480, which it builds on. Review just that commit
(git diff 72dd7c3a0c..HEAD), or after #68480 merges and GitHub re-bases the diff.

Why

KubernetesExecutor._handle_pod_publish_error re-queues a worker-pod CREATE that fails with
an exceeded-quota / stale-version conflict, 500, or 429, but lets 502 / 503 / 504
fall through and fail the task immediately.
Those are exactly the gateway / unavailable /
timeout codes that spike when the API server or an admission webhook is under load — the regime
this executor path is meant to survive. Concretely, the API server returns 503 with
Retry-After: 1
on every graceful shutdown (rollout, node drain, control-plane autoscale —
see apiserver waitgroup.go), so an in-flight pod CREATE during any apiserver roll currently
kills the task outright. client-go retries all 5xx and honours Retry-After; the executor does
not.

What

Add 502 / 503 / 504 to the transient set in the shared _handle_pod_publish_error, so a
task that hits one is re-queued (bounded by task_publish_max_retries) instead of failed. Because
both the sequential and the concurrent creation paths route through that one method, this hardens
both at once, using the executor's existing re-queue-next-loop model — no in-line blocking retry
that would stall the scheduler loop.

When the response carries Retry-After (a shutting-down apiserver always sends it on 503),
pause the whole loop until then via create_pods_after, exactly as the 429 path already does;
other transient 5xx without the header simply retry on the next loop like 500. The Retry-After
parse is guarded so a malformed value can't crash the scheduler loop.

Stacked on #68480 — the shared handler this extends was introduced there.

Tests

Unit, covering both clients through the shared handler: 502 / 504 re-queue and retry on the
next loop; 503 + Retry-After re-queues and backs the loop off; 503 with retries exhausted
fails; and the async client's 503 sets create_pods_after. Each fails without this change.

E2E

Live kind (real API server, real KubernetesExecutor.sync()), with a proxy injecting
503 + Retry-After: 1 on the first CREATE per task — identical config (task_publish_max_retries=3),
only the provider code differs:

Run (retries=3) first loop outcome pods created
before (this branch's parent) 6 tasks → 503 → failed tasks failed 0 / 6
after (async) 503 → all 6 re-queued, backoff retry creates all 6 / 6
after (sequential) 503 → re-queued each loop, no failures later loops create all 6 / 6
Raw — after (async path)
queued 6 tasks dag=retrydag run=after2
loop 0: queue 6->6 failed_events=0          # 503 on first attempt -> all re-queued
loop 1: queue 6->0 failed_events=0          # Retry-After backoff elapsed -> retry creates
FINAL run_id=after2 failed_events=0 queue_remaining=0

# injector
INJECT 503 (Retry-After=1) <- task_id=t000..t005   (6x, first attempt)
PASS   201                 <- task_id=t000..t005   (6x, retry)

# cluster
retrydag-t000..t005   Running   (6 pods)

Risk

Behaviour change on the default sequential path: a 502 / 503 / 504 now re-queues instead of
failing the task — bounded by task_publish_max_retries (default 0, i.e. inert unless retries
are already enabled). The existing 429 / 500 / quota / conflict handling is unchanged.

1fanwang added 7 commits June 18, 2026 10:35
The KubernetesExecutor creates worker pods one at a time within each scheduler
loop: run_next() issues a synchronous create_namespaced_pod and blocks on the
API response before starting the next. When per-create latency is high (API
round-trip, mutating admission webhooks), this serializes the scheduler loop and
caps pod-creation throughput per loop.

Add an opt-in concurrent path: when [kubernetes_executor] async_pod_creation is
True, the pods dequeued in a loop are built synchronously (pod-mutation hook and
reconciliation unchanged), then their create calls are issued concurrently via
the asynchronous Kubernetes client, bounded by pod_creation_max_concurrency. The
sequential path stays the default and is unchanged. A new pod_creation_batch_duration
metric (emitted by both paths) measures per-loop batch creation time.
Move the request-timeout ApiClient wrappers into kube_client.py and build the
executor's async client from _TimeoutAsyncK8sApiClient, so async creates carry
the same client-side request timeout as the rest of the provider and the timeout
logic lives in one place. Drop the config-parse test that only restated values
already exercised by the async-creation tests.
Replace the str(e.status) == "NNN" comparisons in the pod-publish error handler
with http.HTTPStatus constants; type the timeout-wrapper call_api signatures; and
return the concurrent batch's per-pod errors as a position-aligned list instead of
a dict keyed on id(job).
…ync type

The shared pod-publish error handler now matches both the sync and async client
ApiException (they expose the same status/body/reason/headers), mirroring
_should_retry_api. The concurrent path re-raises its native exception, so the
async-to-sync conversion helper is gone.
Trim the batch-metric and concurrent-create docstrings to the why, drop the
refactor-history reference in _build_pod_request, and condense the positional
result-mapping comment.
Add explicit type hints to the remaining locals in the new code (batch counters
and timings, the dequeued job, the error-handler body, and the gather outcomes)
for consistency with the rest of the diff. Verified with mypy.
The KubernetesExecutor's shared pod-publish error handler re-queues
exceeded-quota / stale-version conflicts, 500, and 429, but fails the
task outright on 502/503/504 — the gateway / unavailable / timeout codes
that spike when the API server or an admission webhook is under load. A
shutting-down apiserver returns 503 with Retry-After on every graceful
roll, so an in-flight pod CREATE during a rollout currently kills the
task. Treat these as transient and re-queue them (bounded by
task_publish_max_retries), honoring Retry-After to back the loop off the
same way the 429 path does — matching what client-go already does for
all 5xx.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providers provider:cncf-kubernetes Kubernetes (k8s) provider related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant