This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 6815d040ed1 Add dedicated exceptions for terminal waiter failures and
max-attempt exhaustion, and allow AWS triggers to customize exception-to-event
translation without reimplementing the shared waiter lifecycle. (#72455)
6815d040ed1 is described below
commit 6815d040ed1b170477904f3f1c31316d00b855b4
Author: SameerMesiah97 <[email protected]>
AuthorDate: Fri Sep 4 13:47:41 2026 +0100
Add dedicated exceptions for terminal waiter failures and max-attempt
exhaustion, and allow AWS triggers to customize exception-to-event translation
without reimplementing the shared waiter lifecycle. (#72455)
---
generated/known_airflow_exceptions.txt | 2 +-
.../src/airflow/providers/amazon/aws/exceptions.py | 14 +++++
.../airflow/providers/amazon/aws/triggers/base.py | 13 ++++-
.../amazon/aws/utils/waiter_with_logging.py | 15 ++++--
.../tests/unit/amazon/aws/triggers/test_base.py | 12 +++++
.../amazon/aws/utils/test_waiter_with_logging.py | 63 +++++++++++++++++++++-
6 files changed, 109 insertions(+), 10 deletions(-)
diff --git a/generated/known_airflow_exceptions.txt
b/generated/known_airflow_exceptions.txt
index bfb87136d2e..607b038da83 100644
--- a/generated/known_airflow_exceptions.txt
+++ b/generated/known_airflow_exceptions.txt
@@ -125,7 +125,7 @@
providers/amazon/src/airflow/providers/amazon/aws/triggers/sqs.py::1
providers/amazon/src/airflow/providers/amazon/aws/utils/__init__.py::1
providers/amazon/src/airflow/providers/amazon/aws/utils/connection_wrapper.py::2
providers/amazon/src/airflow/providers/amazon/aws/utils/waiter.py::1
-providers/amazon/src/airflow/providers/amazon/aws/utils/waiter_with_logging.py::6
+providers/amazon/src/airflow/providers/amazon/aws/utils/waiter_with_logging.py::2
providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py::1
providers/apache/beam/src/airflow/providers/apache/beam/hooks/beam.py::8
providers/apache/beam/src/airflow/providers/apache/beam/operators/beam.py::4
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
b/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
index 9760067ce5b..5feb0637e8b 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/exceptions.py
@@ -17,6 +17,8 @@
# under the License.
from __future__ import annotations
+from typing import Any
+
from airflow.providers.common.compat.sdk import AirflowException
# Note: Any AirflowException raised is expected to cause the TaskInstance
@@ -106,3 +108,15 @@ class DataSyncTaskCreationError(AirflowException):
class DataSyncTaskExecutionFailedError(AirflowException):
"""Raised when a DataSync task execution could not be started or did not
complete successfully."""
+
+
+class WaiterTerminalFailure(AirflowException):
+ """Raised when an AWS waiter reaches a terminal failure state."""
+
+ def __init__(self, message: str, last_response: dict[str, Any]):
+ super().__init__(message)
+ self.last_response = last_response
+
+
+class WaiterMaxAttemptsError(AirflowException):
+ """Raised when an AWS waiter exhausts its configured attempts."""
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/triggers/base.py
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/base.py
index 999b5e5bfd1..b952e27fbba 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/triggers/base.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/triggers/base.py
@@ -141,6 +141,15 @@ class AwsBaseWaiterTrigger(BaseTrigger):
def hook(self) -> AwsGenericHook:
"""Override in subclasses to return the right hook."""
+ def _event_from_exception(self, error: AirflowException) -> TriggerEvent:
+ return TriggerEvent(
+ {
+ "status": "error",
+ "message": str(error),
+ self.return_key: self.return_value,
+ }
+ )
+
async def run(self) -> AsyncIterator[TriggerEvent]:
hook = self.hook()
async with await hook.get_async_conn() as client:
@@ -160,7 +169,7 @@ class AwsBaseWaiterTrigger(BaseTrigger):
self.status_message,
self.status_queries,
)
- except AirflowException as e:
- yield TriggerEvent({"status": "error", "message": str(e),
self.return_key: self.return_value})
+ except AirflowException as error:
+ yield self._event_from_exception(error)
else:
yield TriggerEvent({"status": "success", self.return_key:
self.return_value})
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/utils/waiter_with_logging.py
b/providers/amazon/src/airflow/providers/amazon/aws/utils/waiter_with_logging.py
index 60e20bbf2cd..6ae8ecf7d33 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/utils/waiter_with_logging.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/utils/waiter_with_logging.py
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any
import jmespath
from botocore.exceptions import NoCredentialsError, WaiterError
+from airflow.providers.amazon.aws.exceptions import WaiterMaxAttemptsError,
WaiterTerminalFailure
from airflow.providers.common.compat.sdk import AirflowException
if TYPE_CHECKING:
@@ -105,7 +106,10 @@ def wait(
if "terminal failure" in error_reason:
log.error("%s: %s", failure_message,
_LazyStatusFormatter(status_args, last_response))
- raise AirflowException(f"{failure_message}: {error}")
+ raise WaiterTerminalFailure(
+ f"{failure_message}: {error}",
+ last_response=last_response,
+ )
if (
"An error occurred" in error_reason
@@ -130,7 +134,7 @@ def wait(
break
attempt += 1
else:
- raise AirflowException("Waiter error: max attempts reached")
+ raise WaiterMaxAttemptsError("Waiter error: max attempts reached")
async def async_wait(
@@ -184,8 +188,9 @@ async def async_wait(
last_response = error.last_response
if "terminal failure" in error_reason:
- raise AirflowException(
- f"{failure_message}: {_LazyStatusFormatter(status_args,
last_response)}\n{error}"
+ raise WaiterTerminalFailure(
+ f"{failure_message}: {_LazyStatusFormatter(status_args,
last_response)}\n{error}",
+ last_response=last_response,
)
if (
@@ -211,7 +216,7 @@ async def async_wait(
break
attempt += 1
else:
- raise AirflowException("Waiter error: max attempts reached")
+ raise WaiterMaxAttemptsError("Waiter error: max attempts reached")
class _LazyStatusFormatter:
diff --git a/providers/amazon/tests/unit/amazon/aws/triggers/test_base.py
b/providers/amazon/tests/unit/amazon/aws/triggers/test_base.py
index 6423ff9c38c..0c2ea69149a 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_base.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_base.py
@@ -144,3 +144,15 @@ class TestAwsBaseWaiterTrigger:
assert res.payload["status"] == "error"
assert "AWS Glue job failed." in res.payload["message"]
assert res.payload["hello"] == "world"
+
+ def test_event_from_exception(self):
+ self.trigger.return_key = "hello"
+ self.trigger.return_value = "world"
+
+ event = self.trigger._event_from_exception(AirflowException("AWS Glue
job failed."))
+
+ assert event.payload == {
+ "status": "error",
+ "message": "AWS Glue job failed.",
+ "hello": "world",
+ }
diff --git
a/providers/amazon/tests/unit/amazon/aws/utils/test_waiter_with_logging.py
b/providers/amazon/tests/unit/amazon/aws/utils/test_waiter_with_logging.py
index 3414403578e..817be27df2d 100644
--- a/providers/amazon/tests/unit/amazon/aws/utils/test_waiter_with_logging.py
+++ b/providers/amazon/tests/unit/amazon/aws/utils/test_waiter_with_logging.py
@@ -25,6 +25,10 @@ from unittest.mock import AsyncMock
import pytest
from botocore.exceptions import WaiterError
+from airflow.providers.amazon.aws.exceptions import (
+ WaiterMaxAttemptsError,
+ WaiterTerminalFailure,
+)
from airflow.providers.amazon.aws.utils.waiter_with_logging import
_LazyStatusFormatter, async_wait, wait
from airflow.providers.common.compat.sdk import AirflowException
@@ -141,7 +145,7 @@ class TestWaiter:
last_response=generate_response("Pending"),
)
mock_waiter.wait.side_effect = [error, error, error]
- with pytest.raises(AirflowException) as exc:
+ with pytest.raises(WaiterMaxAttemptsError) as exc:
wait(
waiter=mock_waiter,
waiter_delay=123,
@@ -162,6 +166,30 @@ class TestWaiter:
assert mock_waiter.wait.call_count == 2
mock_sleep.assert_called_with(123)
+ @pytest.mark.asyncio
+ async def test_async_wait_max_attempts_exceeded(self):
+ mock_waiter = mock.MagicMock()
+ error = WaiterError(
+ name="test_waiter",
+ reason="test_reason",
+ last_response=generate_response("Pending"),
+ )
+ mock_waiter.wait = AsyncMock(side_effect=error)
+
+ with pytest.raises(WaiterMaxAttemptsError) as exc:
+ await async_wait(
+ waiter=mock_waiter,
+ waiter_delay=0,
+ waiter_max_attempts=2,
+ args={"test_arg": "test_value"},
+ failure_message="test failure message",
+ status_message="test status message",
+ status_args=["Status.State"],
+ )
+
+ assert "Waiter error: max attempts reached" in str(exc.value)
+ assert mock_waiter.wait.call_count == 2
+
@mock.patch("time.sleep")
def test_wait_with_failure(self, mock_sleep):
mock_sleep.return_value = True
@@ -178,7 +206,7 @@ class TestWaiter:
)
mock_waiter.wait.side_effect = [error, error, error, failure_error]
- with pytest.raises(AirflowException) as exc:
+ with pytest.raises(WaiterTerminalFailure) as exc:
wait(
waiter=mock_waiter,
waiter_delay=123,
@@ -198,6 +226,37 @@ class TestWaiter:
)
assert mock_waiter.wait.call_count == 4
+ @pytest.mark.asyncio
+ async def test_async_wait_with_failure(self):
+ mock_waiter = mock.MagicMock()
+ error = WaiterError(
+ name="test_waiter",
+ reason="test_reason",
+ last_response=generate_response("Pending"),
+ )
+ last_response = generate_response("Failure")
+ failure_error = WaiterError(
+ name="test_waiter",
+ reason="terminal failure in waiter",
+ last_response=last_response,
+ )
+ mock_waiter.wait = AsyncMock(side_effect=[error, error, failure_error])
+
+ with pytest.raises(WaiterTerminalFailure) as exc:
+ await async_wait(
+ waiter=mock_waiter,
+ waiter_delay=0,
+ waiter_max_attempts=10,
+ args={"test_arg": "test_value"},
+ failure_message="test failure message",
+ status_message="test status message",
+ status_args=["Status.State"],
+ )
+
+ assert "test failure message" in str(exc.value)
+ assert exc.value.last_response == last_response
+ assert mock_waiter.wait.call_count == 3
+
@mock.patch("time.sleep")
def test_wait_with_unknown_failure(self, mock_sleep):
mock_sleep.return_value = True