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

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new d9dbe635255 Report Kubernetes API 403 errors from the deferrable pod 
trigger (#76081)
d9dbe635255 is described below

commit d9dbe63525537d718903d8f6ee67079af9269834
Author: Henry Chen <[email protected]>
AuthorDate: Sun Nov 1 02:57:27 2026 +0800

    Report Kubernetes API 403 errors from the deferrable pod trigger (#76081)
    
    AsyncKubernetesHook caught urllib3's HTTPError, but it talks to the
    cluster through kubernetes_asyncio over aiohttp, whose ApiException
    descends from OpenApiException and shares no ancestry with HTTPError.
    None of those handlers could run, so a 403 reached KubernetesPodTrigger
    as a bare ApiException and fell through to its generic handler: the task
    failed with an opaque message instead of the guidance about adding the
    triggerer's service account to pod-launcher-role.
    
    Only 403 is translated. Every other status stays an ApiException so that
    generic_api_retry keeps deciding which ones are transient — wrapping
    them would hide the status from its retry predicate and turn retryable
    503s into immediate task failures.
---
 .../providers/cncf/kubernetes/hooks/kubernetes.py  | 23 +++++----
 .../unit/cncf/kubernetes/hooks/test_kubernetes.py  | 57 ++++++++++++++++++++++
 2 files changed, 70 insertions(+), 10 deletions(-)

diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
index b4809db6cf3..1216fd74429 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
@@ -32,10 +32,9 @@ from kubernetes import client, config, utils, watch
 from kubernetes.client.models import V1Deployment
 from kubernetes.config import ConfigException
 from kubernetes_asyncio import client as async_client, config as async_config, 
watch as async_watch
-from urllib3.exceptions import HTTPError
 
 from airflow.models import Connection
-from airflow.providers.cncf.kubernetes.exceptions import KubernetesApiError, 
KubernetesApiPermissionError
+from airflow.providers.cncf.kubernetes.exceptions import 
KubernetesApiPermissionError
 from airflow.providers.cncf.kubernetes.kube_client import (
     _disable_verify_ssl,
     _TimeoutAsyncK8sApiClient,
@@ -1093,10 +1092,12 @@ class AsyncKubernetesHook(KubernetesHook):
                     namespace=namespace,
                 )
                 return pod
-            except HTTPError as e:
-                if hasattr(e, "status") and e.status == 403:
+            except async_client.ApiException as e:
+                if e.status == 403:
                     raise KubernetesApiPermissionError("Permission denied 
(403) from Kubernetes API.") from e
-                raise KubernetesApiError from e
+                # Other statuses stay ApiException: generic_api_retry owns the 
decision about which
+                # ones are transient, and wrapping them here would hide the 
status from its predicate.
+                raise
 
     @generic_api_retry
     async def delete_pod(self, name: str, namespace: str, 
grace_period_seconds: int | None = None):
@@ -1161,8 +1162,10 @@ class AsyncKubernetesHook(KubernetesHook):
                 raw_bytes = await raw_resp.read()
                 # CPU-bound decode/split, offloaded so it can't block the 
triggerer event loop.
                 return await asyncio.to_thread(_split_log_bytes, raw_bytes)
-            except HTTPError as e:
-                raise KubernetesApiError from e
+            except async_client.ApiException as e:
+                if e.status == 403:
+                    raise KubernetesApiPermissionError("Permission denied 
(403) from Kubernetes API.") from e
+                raise
 
     @generic_api_retry
     async def get_pod_events(
@@ -1188,10 +1191,10 @@ class AsyncKubernetesHook(KubernetesHook):
 
                 events: CoreV1EventList = await 
v1_api.list_namespaced_event(**kwargs)
                 return events
-            except HTTPError as e:
-                if hasattr(e, "status") and e.status == 403:
+            except async_client.ApiException as e:
+                if e.status == 403:
                     raise KubernetesApiPermissionError("Permission denied 
(403) from Kubernetes API.") from e
-                raise KubernetesApiError from e
+                raise
 
     @generic_api_retry
     async def watch_pod_events(
diff --git 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py
index 4fed8799e57..c8df520d0ee 100644
--- 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py
+++ 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py
@@ -37,6 +37,7 @@ from kubernetes.config import ConfigException
 from kubernetes_asyncio import client as async_client
 
 from airflow.models import Connection
+from airflow.providers.cncf.kubernetes.exceptions import 
KubernetesApiPermissionError
 from airflow.providers.cncf.kubernetes.hooks.kubernetes import (
     AsyncKubernetesHook,
     KubernetesHook,
@@ -2026,6 +2027,62 @@ class TestAsyncKubernetesHook:
         assert logs == ["decoded line"]
         mock_to_thread.assert_awaited_once_with(_split_log_bytes, raw_bytes)
 
+    # The async hook talks to kubernetes_asyncio, whose ApiException does not 
derive from urllib3's
+    # HTTPError, so these statuses only reach the handlers through the async 
exception type.
+    ASYNC_API_CALLS = [
+        pytest.param(
+            "get_pod", "read_namespaced_pod", {"name": POD_NAME, "namespace": 
NAMESPACE}, id="get_pod"
+        ),
+        pytest.param(
+            "read_logs", "read_namespaced_pod_log", {"name": POD_NAME, 
"namespace": NAMESPACE}, id="read_logs"
+        ),
+        pytest.param(
+            "get_pod_events",
+            "list_namespaced_event",
+            {"name": POD_NAME, "namespace": NAMESPACE},
+            id="get_pod_events",
+        ),
+    ]
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(("hook_method", "api_method", "call_kwargs"), 
ASYNC_API_CALLS)
+    async def test_async_api_403_raises_permission_error(
+        self, hook_method, api_method, call_kwargs, kube_config_loader
+    ):
+        hook = AsyncKubernetesHook(
+            conn_id=None,
+            in_cluster=False,
+            config_file=None,
+            cluster_context=None,
+        )
+
+        with mock.patch(self.KUBE_API.format(api_method), autospec=True) as 
lib_method:
+            lib_method.side_effect = async_client.ApiException(status=403, 
reason="Forbidden")
+
+            with pytest.raises(KubernetesApiPermissionError, match="Permission 
denied \\(403\\)"):
+                await getattr(hook, hook_method)(**call_kwargs)
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(("hook_method", "api_method", "call_kwargs"), 
ASYNC_API_CALLS)
+    async def test_async_api_other_status_stays_api_exception(
+        self, hook_method, api_method, call_kwargs, kube_config_loader
+    ):
+        """Non-403 statuses must reach generic_api_retry unwrapped so it can 
judge transience itself."""
+        hook = AsyncKubernetesHook(
+            conn_id=None,
+            in_cluster=False,
+            config_file=None,
+            cluster_context=None,
+        )
+
+        with mock.patch(self.KUBE_API.format(api_method), autospec=True) as 
lib_method:
+            lib_method.side_effect = async_client.ApiException(status=404, 
reason="Not Found")
+
+            with pytest.raises(async_client.ApiException) as exc_info:
+                await getattr(hook, hook_method)(**call_kwargs)
+
+        assert exc_info.value.status == 404
+
     @pytest.mark.asyncio
     @mock.patch(KUBE_BATCH_API.format("read_namespaced_job_status"))
     async def test_get_job_status(self, lib_method, kube_config_loader):

Reply via email to