Skip to content

Commit 4f930ce

Browse files
committed
Fix KubernetesPodOperator 404 on pod preemption
1 parent a1daaf1 commit 4f930ce

6 files changed

Lines changed: 8140 additions & 8001 deletions

File tree

providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
container_is_completed,
4646
container_is_running,
4747
)
48+
from airflow.providers.cncf.kubernetes.utils.pod_manager import PodNotFoundException
4849
from airflow.providers.common.compat.connection import get_async_connection
4950
from airflow.providers.common.compat.sdk import AirflowException, AirflowNotFoundException, BaseHook
5051
from airflow.utils import yaml
@@ -1065,25 +1066,51 @@ async def get_conn(self) -> AsyncGenerator[async_client.ApiClient, None]:
10651066
await kube_client.close()
10661067

10671068
@generic_api_retry
1068-
async def get_pod(self, name: str, namespace: str) -> V1Pod:
1069+
async def get_pod(self, name: str, namespace: str, *, pod: V1Pod | None = None) -> V1Pod:
10691070
"""
10701071
Get pod's object.
10711072
10721073
:param name: Name of the pod.
10731074
:param namespace: Name of the pod's namespace.
1075+
:param pod: The last known pod object (optional), used to check if the pod was running.
10741076
"""
10751077
async with self.get_conn() as connection:
1076-
try:
1077-
v1_api = async_client.CoreV1Api(connection)
1078-
pod: V1Pod = await v1_api.read_namespaced_pod(
1079-
name=name,
1080-
namespace=namespace,
1081-
)
1082-
return pod
1083-
except HTTPError as e:
1084-
if hasattr(e, "status") and e.status == 403:
1085-
raise KubernetesApiPermissionError("Permission denied (403) from Kubernetes API.") from e
1086-
raise KubernetesApiError from e
1078+
v1_api = async_client.CoreV1Api(connection)
1079+
retries = 3
1080+
delay = 2
1081+
for attempt in range(retries + 1):
1082+
try:
1083+
current_pod: V1Pod = await v1_api.read_namespaced_pod(
1084+
name=name,
1085+
namespace=namespace,
1086+
)
1087+
return current_pod
1088+
except async_client.ApiException as e:
1089+
if e.status == 404:
1090+
was_running = (
1091+
pod and pod.status and pod.status.phase and pod.status.phase != "Pending"
1092+
)
1093+
if attempt < retries and not was_running:
1094+
self.log.info(
1095+
"Pod '%s' not found in namespace '%s'. Retrying in %s seconds...",
1096+
name,
1097+
namespace,
1098+
delay,
1099+
)
1100+
await asyncio.sleep(delay)
1101+
delay *= 2
1102+
continue
1103+
raise PodNotFoundException(
1104+
f"Pod '{name}' not found in namespace '{namespace}'. "
1105+
f"This may be caused by pod preemption (e.g., by higher-priority daemonset pods)."
1106+
) from e
1107+
raise
1108+
except HTTPError as e:
1109+
if hasattr(e, "status") and e.status == 403:
1110+
raise KubernetesApiPermissionError(
1111+
"Permission denied (403) from Kubernetes API."
1112+
) from e
1113+
raise KubernetesApiError from e
10871114

10881115
@generic_api_retry
10891116
async def delete_pod(self, name: str, namespace: str, grace_period_seconds: int | None = None):

providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/pod.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def __init__(
148148
self.trigger_kwargs = trigger_kwargs or {}
149149
self._fired_event = False
150150
self._since_time = None
151+
self.last_pod: V1Pod | None = None
151152

152153
def serialize(self) -> tuple[str, dict[str, Any]]:
153154
"""Serialize KubernetesCreatePodTrigger arguments and classpath."""
@@ -416,7 +417,8 @@ async def _wait_for_container_completion(self) -> TriggerEvent:
416417
@tenacity.retry(stop=tenacity.stop_after_attempt(3), wait=tenacity.wait_exponential(), reraise=True)
417418
async def _get_pod(self) -> V1Pod:
418419
"""Get the pod from Kubernetes with retries."""
419-
pod = await self.hook.get_pod(name=self.pod_name, namespace=self.pod_namespace)
420+
pod = await self.hook.get_pod(name=self.pod_name, namespace=self.pod_namespace, pod=self.last_pod)
421+
self.last_pod = pod
420422
# Due to AsyncKubernetesHook overriding get_pod, we need to cast the return
421423
# value to kubernetes_asyncio.V1Pod, because it's perceived as different type
422424
return cast("V1Pod", pod)

providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -941,10 +941,31 @@ def read_pod_events(self, pod: V1Pod, resource_version: str | None = None) -> Co
941941
@generic_api_retry
942942
def read_pod(self, pod: V1Pod) -> V1Pod:
943943
"""Read POD information."""
944-
try:
945-
return self._client.read_namespaced_pod(pod.metadata.name, pod.metadata.namespace)
946-
except HTTPError as e:
947-
raise KubernetesApiException(f"There was an error reading the kubernetes API: {e}")
944+
retries = 3
945+
delay = 2
946+
for attempt in range(retries + 1):
947+
try:
948+
return self._client.read_namespaced_pod(pod.metadata.name, pod.metadata.namespace)
949+
except ApiException as e:
950+
if e.status == 404:
951+
was_running = pod.status and pod.status.phase and pod.status.phase != "Pending"
952+
if attempt < retries and not was_running:
953+
self.log.info(
954+
"Pod '%s' not found in namespace '%s'. Retrying in %s seconds...",
955+
pod.metadata.name,
956+
pod.metadata.namespace,
957+
delay,
958+
)
959+
time.sleep(delay)
960+
delay *= 2
961+
continue
962+
raise PodNotFoundException(
963+
f"Pod '{pod.metadata.name}' not found in namespace '{pod.metadata.namespace}'. "
964+
f"This may be caused by pod preemption (e.g., by higher-priority daemonset pods)."
965+
) from e
966+
raise
967+
except HTTPError as e:
968+
raise KubernetesApiException(f"There was an error reading the kubernetes API: {e}")
948969

949970
def await_xcom_sidecar_container_start(
950971
self, pod: V1Pod, timeout: int = 900, log_interval: int = 30

providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
API_TIMEOUT,
4747
API_TIMEOUT_OFFSET_SERVER_SIDE,
4848
)
49+
from airflow.providers.cncf.kubernetes.utils.pod_manager import PodNotFoundException
4950
from airflow.providers.common.compat.sdk import AirflowException, AirflowNotFoundException
5051

5152
from tests_common.test_utils.db import clear_test_connections
@@ -1741,6 +1742,60 @@ async def test_get_pod(self, lib_method, kube_config_loader):
17411742
namespace=NAMESPACE,
17421743
)
17431744

1745+
@pytest.mark.asyncio
1746+
@mock.patch("asyncio.sleep")
1747+
@mock.patch(KUBE_API.format("read_namespaced_pod"))
1748+
async def test_get_pod_raises_pod_not_found_on_404(self, lib_method, mock_sleep, kube_config_loader):
1749+
"""When the K8s API returns 404 (pod preempted/deleted), raise PodNotFoundException after retries."""
1750+
lib_method.side_effect = async_client.ApiException(status=404, reason="Not Found")
1751+
1752+
hook = AsyncKubernetesHook(
1753+
conn_id=None,
1754+
in_cluster=False,
1755+
config_file=None,
1756+
cluster_context=None,
1757+
)
1758+
1759+
with pytest.raises(PodNotFoundException, match="not found"):
1760+
await hook.get_pod(
1761+
name=POD_NAME,
1762+
namespace=NAMESPACE,
1763+
)
1764+
1765+
# Verify 3 retries (4 calls total)
1766+
assert lib_method.call_count == 4
1767+
# Verify sleep times: 2s, 4s, 8s
1768+
mock_sleep.assert_has_calls([mock.call(2), mock.call(4), mock.call(8)])
1769+
1770+
@pytest.mark.asyncio
1771+
@mock.patch("asyncio.sleep")
1772+
@mock.patch(KUBE_API.format("read_namespaced_pod"))
1773+
async def test_get_pod_raises_pod_not_found_on_404_no_retry_if_running(
1774+
self, lib_method, mock_sleep, kube_config_loader
1775+
):
1776+
"""When the pod was previously running, raise PodNotFoundException immediately on 404 without retries."""
1777+
lib_method.side_effect = async_client.ApiException(status=404, reason="Not Found")
1778+
1779+
hook = AsyncKubernetesHook(
1780+
conn_id=None,
1781+
in_cluster=False,
1782+
config_file=None,
1783+
cluster_context=None,
1784+
)
1785+
1786+
mock_pod = mock.MagicMock()
1787+
mock_pod.status.phase = "Running"
1788+
1789+
with pytest.raises(PodNotFoundException, match="not found"):
1790+
await hook.get_pod(
1791+
name=POD_NAME,
1792+
namespace=NAMESPACE,
1793+
pod=mock_pod,
1794+
)
1795+
1796+
assert lib_method.call_count == 1
1797+
mock_sleep.assert_not_called()
1798+
17441799
@pytest.mark.asyncio
17451800
@mock.patch(KUBE_API.format("delete_namespaced_pod"))
17461801
async def test_delete_pod(self, lib_method, kube_config_loader):

providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
AsyncPodManager,
3535
PodLogsConsumer,
3636
PodManager,
37+
PodNotFoundException,
3738
PodPhase,
3839
XComRetrievalError,
3940
_parse_log_level,
@@ -709,6 +710,39 @@ def test_read_pod_retries_fails(self):
709710
with pytest.raises(AirflowException):
710711
self.pod_manager.read_pod(mock.sentinel)
711712

713+
@mock.patch("time.sleep")
714+
def test_read_pod_raises_pod_not_found_on_404(self, mock_sleep):
715+
"""When the K8s API returns 404 (pod preempted/deleted), raise PodNotFoundException."""
716+
mock.sentinel.metadata = mock.MagicMock()
717+
self.mock_kube_client.read_namespaced_pod.side_effect = ApiException(status=404, reason="Not Found")
718+
with pytest.raises(PodNotFoundException, match="not found"):
719+
self.pod_manager.read_pod(mock.sentinel)
720+
721+
# Verify 3 retries (4 calls total)
722+
assert self.mock_kube_client.read_namespaced_pod.call_count == 4
723+
# Verify sleep times: 2s, 4s, 8s
724+
mock_sleep.assert_has_calls([mock.call(2), mock.call(4), mock.call(8)])
725+
726+
@mock.patch("time.sleep")
727+
def test_read_pod_raises_pod_not_found_on_404_no_retry_if_running(self, mock_sleep):
728+
"""When the K8s API returns 404 but pod was previously running, raise PodNotFoundException immediately."""
729+
mock.sentinel.metadata = mock.MagicMock()
730+
mock.sentinel.status = mock.MagicMock(phase="Running")
731+
self.mock_kube_client.read_namespaced_pod.side_effect = ApiException(status=404, reason="Not Found")
732+
733+
with pytest.raises(PodNotFoundException, match="not found"):
734+
self.pod_manager.read_pod(mock.sentinel)
735+
736+
assert self.mock_kube_client.read_namespaced_pod.call_count == 1
737+
mock_sleep.assert_not_called()
738+
739+
def test_read_pod_reraises_non_404_api_exception(self):
740+
"""Non-404 ApiException errors that are not transient should still propagate."""
741+
mock.sentinel.metadata = mock.MagicMock()
742+
self.mock_kube_client.read_namespaced_pod.side_effect = ApiException(status=403, reason="Forbidden")
743+
with pytest.raises(ApiException):
744+
self.pod_manager.read_pod(mock.sentinel)
745+
712746
@mock.patch("airflow.providers.cncf.kubernetes.utils.pod_manager.PodManager.container_is_running")
713747
@mock.patch("airflow.providers.cncf.kubernetes.utils.pod_manager.PodManager.read_pod_logs")
714748
def test_fetch_container_logs_returning_last_timestamp(
@@ -1451,6 +1485,20 @@ async def test_read_pod_events_without_resource_version(self):
14511485
"test-pod", "test-namespace", resource_version=None
14521486
)
14531487

1488+
@pytest.mark.asyncio
1489+
async def test_read_pod_raises_pod_not_found_on_404(self):
1490+
"""When the hook raises PodNotFoundException (404), it propagates from read_pod."""
1491+
mock_pod = mock.Mock()
1492+
mock_pod.metadata.namespace = "test-namespace"
1493+
mock_pod.metadata.name = "test-pod"
1494+
1495+
self.mock_async_hook.get_pod.side_effect = PodNotFoundException(
1496+
"Pod 'test-pod' not found in namespace 'test-namespace'."
1497+
)
1498+
1499+
with pytest.raises(PodNotFoundException, match="not found"):
1500+
await self.async_pod_manager.read_pod(mock_pod)
1501+
14541502
@pytest.mark.asyncio
14551503
async def test_watch_pod_events_uses_hook_watch(self):
14561504
"""Test that watch_pod_events uses hook's watch_pod_events method."""

0 commit comments

Comments
 (0)