This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new 53717ecc0cf KPO: treat registry 5xx errors as transient during pod 
startup (#65490) (#66170)
53717ecc0cf is described below

commit 53717ecc0cfeab7f9c0e49340008112b5e8ffdd3
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Apr 30 20:32:07 2026 +0200

    KPO: treat registry 5xx errors as transient during pod startup (#65490) 
(#66170)
    
    When a pod is starting, kubelet automatically retries failed image pulls
    with exponential backoff. `KubernetesPodOperator` monitors the pod via
    `detect_pod_terminate_early_issues()` and aborts early on what it deems a
    fatal pull error — but the transient-error pattern list did not cover
    registry 5xx / gateway outages. A short Docker Hub outage (502/503/504
    from `auth.docker.io` or the registry) was being classified as fatal and
    would abort the pod before kubelet's next retry, even though the pull
    would have succeeded once upstream recovered.
    
    Add `bad gateway`, `service unavailable`, `gateway timeout` to
    `TRANSIENT_ERROR_PATTERNS` so these conditions let kubelet keep retrying
    within the caller's `startup_timeout` budget.
    
    On kubelet >= 1.32 the `ImagePullBackOff` message is
    `Back-off pulling image "X": <prev_error>`, so these patterns also match
    during the backoff state. On older kubelets the bare message carries no
    detail and we keep the existing fail-fast behaviour — matching
    `back-off pulling` unconditionally would have caused a 120s wait for a
    genuinely missing image instead of an immediate error (thanks Jens for
    catching this).
    
    `startup_timeout` still bounds the overall wait, and genuinely fatal pull
    errors (`InvalidImageName`, `ErrImageNeverPull`, `manifest unknown`,
    `unauthorized`, …) remain fatal.
    
    (cherry picked from commit 938ccf3b2fc3238d36eca621a4580fd9424d161d)
---
 .../providers/cncf/kubernetes/utils/pod_manager.py | 12 +++++
 .../unit/cncf/kubernetes/utils/test_pod_manager.py | 56 ++++++++++++++++++++++
 2 files changed, 68 insertions(+)

diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
index 2c2405b7fd4..a48354bc3c3 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
@@ -203,6 +203,18 @@ def detect_pod_terminate_early_issues(pod: V1Pod) -> str | 
None:
         "temporarily unavailable",
         "timeout",
         "account limit",
+        # Upstream registry/auth outages (e.g. Docker Hub auth returning 5xx).
+        # kubelet will retry automatically; the caller's startup_timeout still
+        # bounds the total wait. These match both the underlying ErrImagePull
+        # message and the ImagePullBackOff message on kubelet >= 1.32, which
+        # appends the previous pull error to "Back-off pulling image ...".
+        # On older kubelets the bare ImagePullBackOff message carries no
+        # detail, so we fall back to the existing fail-fast path — matching
+        # "back-off pulling" unconditionally would cause a 120s wait for a
+        # genuinely missing image instead of failing fast.
+        "bad gateway",
+        "service unavailable",
+        "gateway timeout",
     ]
 
     FATAL_STATES = ["InvalidImageName", "ErrImageNeverPull"]
diff --git 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
index b5c57c49167..f1cbc9be8d6 100644
--- 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
+++ 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
@@ -36,6 +36,7 @@ from airflow.providers.cncf.kubernetes.utils.pod_manager 
import (
     PodManager,
     PodPhase,
     XComRetrievalError,
+    detect_pod_terminate_early_issues,
     log_pod_event,
     parse_log_line,
 )
@@ -145,6 +146,61 @@ def test_log_pod_event_multiple_events():
     assert mock_pod_manager.log.info.call_count == 2
 
 
+def _pod_with_waiting_container(reason: str, message: str):
+    pod = mock.MagicMock()
+    container_status = mock.MagicMock()
+    waiting_state = mock.MagicMock()
+    waiting_state.reason = reason
+    waiting_state.message = message
+    container_status.state.waiting = waiting_state
+    pod.status.container_statuses = [container_status]
+    return pod
+
+
[email protected](
+    ("reason", "message"),
+    [
+        ("ErrImagePull", "rpc error: 502 Bad Gateway from auth.docker.io"),
+        ("ErrImagePull", "registry returned 503 Service Unavailable"),
+        ("ErrImagePull", "got 504 Gateway Timeout"),
+        # kubelet >= 1.32 appends the previous pull error to the
+        # ImagePullBackOff message, so the 5xx patterns still match.
+        ("ImagePullBackOff", 'Back-off pulling image "ubuntu:latest": 502 Bad 
Gateway'),
+        ("ErrImagePull", "too many requests"),
+        ("ErrImagePull", "pull QPS exceeded"),
+    ],
+)
+def 
test_detect_pod_terminate_early_issues_returns_none_for_transient_messages(reason,
 message):
+    """Transient registry/network errors should not trigger early termination.
+
+    kubelet retries image pulls automatically; `startup_timeout` bounds the
+    total wait. Returning None here keeps the monitoring loop going so those
+    retries have a chance to succeed.
+    """
+    pod = _pod_with_waiting_container(reason, message)
+    assert detect_pod_terminate_early_issues(pod) is None
+
+
[email protected](
+    ("reason", "message"),
+    [
+        ("InvalidImageName", "Failed to apply default image tag"),
+        ("ErrImageNeverPull", "Container image pull policy is Never"),
+        ("ErrImagePull", "manifest unknown"),
+        ("ImagePullBackOff", "unauthorized: authentication required"),
+        # Bare ImagePullBackOff message (kubelet < 1.32) — must fail fast so
+        # a genuinely missing image doesn't wait out startup_timeout.
+        ("ImagePullBackOff", 'Back-off pulling image "nonexistent:latest"'),
+    ],
+)
+def 
test_detect_pod_terminate_early_issues_returns_error_for_fatal_messages(reason, 
message):
+    pod = _pod_with_waiting_container(reason, message)
+    error = detect_pod_terminate_early_issues(pod)
+    assert error is not None
+    assert reason in error
+    assert message in error
+
+
 class TestPodManager:
     def setup_method(self):
         self.mock_kube_client = mock.Mock()

Reply via email to