This is an automated email from the ASF dual-hosted git repository.
potiuk 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 5f03059ee9b fix: coerce explicit None _request_timeout in async/sync
k8s API clients (#69611)
5f03059ee9b is described below
commit 5f03059ee9b888c1d31268e66c0ad1c8bb8b9554
Author: Mingjie Zhao <[email protected]>
AuthorDate: Thu Aug 13 20:34:56 2026 +0800
fix: coerce explicit None _request_timeout in async/sync k8s API clients
(#69611)
The _TimeoutAsyncK8sApiClient and _TimeoutK8sApiClient wrappers attempt
to enforce a client-side read timeout via kwargs.setdefault(). However,
every generated kubernetes / kubernetes_asyncio API method (e.g.
read_namespaced_pod) always forwards _request_timeout=None explicitly:
_request_timeout=local_var_params.get('_request_timeout') # → None
setdefault() is a no-op when the key already exists, even as None. So
None reaches aiohttp, which creates ClientTimeout() with no total or
sock_read limit — an infinite socket timeout.
On a half-open TCP connection (what a 429 Too Many Requests or an
LB-side "500 context canceled" leaves behind when the LB drops the
socket without FIN/RST), the awaited API call blocks forever. Because
no exception is raised, generic_api_retry never fires and a deferrable
KubernetesPodOperator trigger hangs indefinitely with no error visible
in the task log. Only a triggerer restart clears it.
Fix: replace setdefault with an explicit None-check so that a None
value (from the generated client) is correctly treated as "not set",
while any intentionally-set value — including the (1800, 300) tuple
passed by the async log-streaming path — is preserved.
Confirmed by live introspection in production (kubernetes_asyncio 35.0.1,
aiohttp 3.13.2, apache-airflow-providers-cncf-kubernetes 10.17.1):
before the fix, _request_timeout=None was forwarded to aiohttp regardless
of the wrapper; after the fix, it correctly receives 60.
Ref:
https://github.com/kubernetes-client/python/blob/master/examples/watch/timeout-settings.md
Co-authored-by: Mingjie Zhao <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../airflow/providers/cncf/kubernetes/kube_client.py | 19 +++++++++++++------
.../unit/cncf/kubernetes/hooks/test_kubernetes.py | 10 ++++++++++
2 files changed, 23 insertions(+), 6 deletions(-)
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
index 79e2b802e01..08f91abe73e 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
@@ -79,9 +79,12 @@ try:
def call_api(self, *args: Any, **kwargs: Any) -> Any:
timeout_seconds = kwargs.get("timeout_seconds") # get server-side
timeout
- kwargs.setdefault(
- "_request_timeout", _get_request_timeout(timeout_seconds)
- ) # client-side timeout
+ # Use setdefault's intent but handle explicit None: generated
kubernetes client methods always
+ # pass _request_timeout=None explicitly, making setdefault a no-op
and leaving an infinite
+ # timeout. On a half-open TCP connection (e.g. after a 429 or
LB-side 500), this causes the
+ # call to hang indefinitely with no exception, preventing any
retry or recovery.
+ if kwargs.get("_request_timeout") is None:
+ kwargs["_request_timeout"] =
_get_request_timeout(timeout_seconds)
return super().call_api(*args, **kwargs)
class _TimeoutAsyncK8sApiClient(async_client.ApiClient):
@@ -95,9 +98,13 @@ try:
async def call_api(self, *args: Any, **kwargs: Any) -> Any:
timeout_seconds = kwargs.get("timeout_seconds") # server-side
timeout
- kwargs.setdefault(
- "_request_timeout", _get_request_timeout(timeout_seconds)
- ) # client-side timeout
+ # Use setdefault's intent but handle explicit None: generated
kubernetes_asyncio client methods
+ # always pass _request_timeout=None explicitly, making setdefault
a no-op and leaving an
+ # infinite timeout. On a half-open TCP connection (e.g. after a
429 or LB-side 500), this
+ # causes the trigger's await to hang indefinitely with no
exception, preventing any retry or
+ # recovery.
+ if kwargs.get("_request_timeout") is None:
+ kwargs["_request_timeout"] =
_get_request_timeout(timeout_seconds)
return await super().call_api(*args, **kwargs)
except ImportError as e:
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 7105cfad845..09d686821ca 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
@@ -94,7 +94,12 @@ class TestTimeoutK8sApiClient:
("kwargs", "expected_timeout"),
[
pytest.param({}, API_TIMEOUT, id="default-timeout"),
+ # Generated kubernetes client methods always pass
_request_timeout=None explicitly,
+ # so setdefault() is a no-op. The fix must coerce None to the
default timeout.
+ pytest.param({"_request_timeout": None}, API_TIMEOUT,
id="explicit-none-timeout"),
pytest.param({"timeout_seconds": 5678, "_request_timeout": 1234},
1234, id="explicit-timeout"),
+ # Log-streaming path passes a (connection, read) tuple; it must
not be clobbered.
+ pytest.param({"_request_timeout": (1800, 300)}, (1800, 300),
id="explicit-tuple-preserved"),
pytest.param(
{"timeout_seconds": API_TIMEOUT -
API_TIMEOUT_OFFSET_SERVER_SIDE},
API_TIMEOUT,
@@ -128,7 +133,12 @@ class TestTimeoutAsyncK8sApiClient:
("kwargs", "expected_timeout"),
[
pytest.param({}, API_TIMEOUT, id="default-timeout"),
+ # Generated kubernetes_asyncio client methods always pass
_request_timeout=None explicitly,
+ # so setdefault() is a no-op. The fix must coerce None to the
default timeout.
+ pytest.param({"_request_timeout": None}, API_TIMEOUT,
id="explicit-none-timeout"),
pytest.param({"timeout_seconds": 5678, "_request_timeout": 1234},
1234, id="explicit-timeout"),
+ # Log-streaming path passes a (connection, read) tuple; it must
not be clobbered.
+ pytest.param({"_request_timeout": (1800, 300)}, (1800, 300),
id="explicit-tuple-preserved"),
pytest.param(
{"timeout_seconds": API_TIMEOUT -
API_TIMEOUT_OFFSET_SERVER_SIDE},
API_TIMEOUT,