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

o-nikolas 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 d12021da679 Allow ECS exit codes to fail tasks without retrying 
(#73885)
d12021da679 is described below

commit d12021da679d57a0eb1da65fca9ded9a0ed154a8
Author: Jingi Kim <[email protected]>
AuthorDate: Sat Oct 24 00:43:19 2026 +0900

    Allow ECS exit codes to fail tasks without retrying (#73885)
    
    Allow Dag authors to stop retries for known application failures reported 
by an ECS container. For example, with retries=2 and fail_on_exit_code=2, an 
invalid-input exit code fails the Airflow task after its first attempt, while 
other exit codes retain the normal retry behavior.
---
 providers/amazon/docs/operators/ecs.rst            | 36 +++++++++++++++
 .../airflow/providers/amazon/aws/operators/ecs.py  | 38 +++++++++++++--
 .../tests/unit/amazon/aws/operators/test_ecs.py    | 54 +++++++++++++++++++++-
 3 files changed, 124 insertions(+), 4 deletions(-)

diff --git a/providers/amazon/docs/operators/ecs.rst 
b/providers/amazon/docs/operators/ecs.rst
index ee46902373b..8e136e10626 100644
--- a/providers/amazon/docs/operators/ecs.rst
+++ b/providers/amazon/docs/operators/ecs.rst
@@ -173,6 +173,42 @@ all replace the task definition's value rather than adding 
to it. See
 `ContainerOverride 
<https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerOverride.html>`__
 in the Amazon ECS API reference for the full list of fields.
 
+Control retries for container exit codes
+""""""""""""""""""""""""""""""""""""""""
+
+Use ``fail_on_exit_code`` when a known application failure should fail the 
Airflow task
+without consuming its remaining retries. Other failures continue to use the 
task's
+normal retry settings. For example, if your application uses exit code ``2`` 
for an
+invalid input that will not succeed on retry:
+
+.. code-block:: python
+
+    EcsRunTaskOperator(
+        task_id="run_application",
+        cluster="application_cluster",
+        task_definition="application_task",
+        overrides={},
+        retries=2,
+        fail_on_exit_code=2,
+    )
+
+The option accepts an integer or a container of integers. Its default is 
``None``,
+which preserves the existing retry behavior. Exit code ``0`` remains 
successful;
+a missing exit code does not match this option.
+
+If any stopped container explicitly reports a matching non-zero code, the task 
fails
+without retrying, even if another container reports a different failure or a 
code in
+``skip_on_exit_code``. This result does not depend on container order. When 
the same
+code appears in both options, ``fail_on_exit_code`` takes precedence. 
Task-level
+startup failures, host termination checks, and AWS API errors retain their 
existing
+handling.
+
+This behavior applies to synchronous execution and resumed deferrable 
execution.
+The option has no effect when ``wait_for_completion=False`` and 
``deferrable=False``:
+the operator returns before checking exit codes. On Airflow versions supporting
+``retry_policy``, a matching code raises ``AirflowFailException``, which 
bypasses
+retry policies, including a policy that would otherwise retry the task.
+
 Stream logs to AWS CloudWatch
 """""""""""""""""""""""""""""
 
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py
index aad4678df5a..a82931b1263 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/ecs.py
@@ -39,7 +39,12 @@ from airflow.providers.amazon.aws.utils import 
validate_execute_complete_event
 from airflow.providers.amazon.aws.utils.identifiers import generate_uuid
 from airflow.providers.amazon.aws.utils.mixins import aws_template_fields
 from airflow.providers.amazon.aws.utils.task_log_fetcher import 
AwsTaskLogFetcher
-from airflow.providers.common.compat.sdk import AirflowException, 
AirflowSkipException, conf
+from airflow.providers.common.compat.sdk import (
+    AirflowException,
+    AirflowFailException,
+    AirflowSkipException,
+    conf,
+)
 from airflow.utils.helpers import prune_dict
 
 if TYPE_CHECKING:
@@ -404,6 +409,10 @@ class EcsRunTaskOperator(EcsBaseOperator):
     :param skip_on_exit_code: If task exits with this exit code, leave the task
         in ``skipped`` state (default: None). If set to ``None``, any non-zero
         exit code will be treated as a failure. Can be an int or a container 
of ints.
+    :param fail_on_exit_code: Non-zero exit codes that fail the task without 
retrying.
+        Can be an int or a container of ints (default: None). A matching code 
reported
+        by a stopped container takes precedence over ``skip_on_exit_code`` and 
other
+        container failures. Missing exit codes do not match this option.
     :param do_xcom_push: If True, the operator will push the ECS task ARN to 
XCom with key 'ecs_task_arn'.
         Additionally, if logs are fetched, the last log message will be pushed 
to XCom with the key 'return_value'. (default: False)
     :param stop_task_on_failure: If True, attempt to stop the ECS task if the 
Airflow task fails
@@ -472,6 +481,7 @@ class EcsRunTaskOperator(EcsBaseOperator):
         # Airflow execution_timeout handles task timeout
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
         skip_on_exit_code: int | Container[int] | None = None,
+        fail_on_exit_code: int | Container[int] | None = None,
         stop_task_on_failure: bool = True,
         **kwargs,
     ):
@@ -516,6 +526,13 @@ class EcsRunTaskOperator(EcsBaseOperator):
             else []
         )
         self.stop_task_on_failure = stop_task_on_failure
+        self.fail_on_exit_code = (
+            fail_on_exit_code
+            if isinstance(fail_on_exit_code, Container)
+            else [fail_on_exit_code]
+            if fail_on_exit_code is not None
+            else []
+        )
 
     @staticmethod
     def _get_ecs_task_id(task_arn: str | None) -> str | None:
@@ -787,11 +804,26 @@ class EcsRunTaskOperator(EcsBaseOperator):
                     f" {task.get('stoppedReason', '')}"
                 )
             containers = task["containers"]
+            if self.fail_on_exit_code:
+                failed_container = next(
+                    (
+                        container
+                        for container in containers
+                        if container.get("lastStatus") == "STOPPED"
+                        and container.get("exitCode") not in (None, 0)
+                        and container["exitCode"] in self.fail_on_exit_code
+                    ),
+                    None,
+                )
+                if failed_container is not None:
+                    containers = [failed_container]
             for container in containers:
                 if container.get("lastStatus") == "STOPPED" and 
container.get("exitCode", 1) != 0:
                     exit_code = container.get("exitCode", 1)
-                    if exit_code in self.skip_on_exit_code:
-                        exception_cls: type[AirflowException] = 
AirflowSkipException
+                    if container.get("exitCode") is not None and exit_code in 
self.fail_on_exit_code:
+                        exception_cls: type[AirflowException] = 
AirflowFailException
+                    elif exit_code in self.skip_on_exit_code:
+                        exception_cls = AirflowSkipException
                     else:
                         exception_cls = AirflowException
 
diff --git a/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py
index b53ca45d9c6..6887c392ea7 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_ecs.py
@@ -40,10 +40,16 @@ from airflow.providers.amazon.aws.operators.ecs import (
 from airflow.providers.amazon.aws.triggers.ecs import TaskDoneTrigger
 from airflow.providers.amazon.aws.utils.task_log_fetcher import 
AwsTaskLogFetcher
 from airflow.providers.amazon.version_compat import NOTSET
-from airflow.providers.common.compat.sdk import AirflowException, 
AirflowSkipException, TaskDeferred
+from airflow.providers.common.compat.sdk import (
+    AirflowException,
+    AirflowFailException,
+    AirflowSkipException,
+    TaskDeferred,
+)
 
 from unit.amazon.aws.utils.test_template_fields import validate_template_fields
 
+TASK_ARN = 
"arn:aws:ecs:us-east-1:123456789012:task/probe/00000000000000000000000000000001"
 CLUSTER_NAME = "test_cluster"
 CONTAINER_NAME = "e1ed7aac-d9b2-4315-8726-d2432bf11868"
 TASK_ID = "d8c67b3c-ac87-4ffe-a847-4785bc3a8b55"
@@ -665,6 +671,52 @@ class TestEcsRunTaskOperator(EcsBaseTestCase):
             self.ecs._check_success_task()
         assert type(ctx.value) is AirflowException
 
+    @pytest.mark.parametrize(
+        ("fail_codes", "skip_codes", "exit_codes", "expected"),
+        [
+            pytest.param(2, None, [2], AirflowFailException, id="int_match"),
+            pytest.param([2, 3], None, [3], AirflowFailException, 
id="list_match"),
+            pytest.param(2, None, [1], AirflowException, id="unmatched_code"),
+            pytest.param(1, None, [None], AirflowException, 
id="missing_exit_code"),
+            pytest.param(2, 2, [2], AirflowFailException, id="fail_over_skip"),
+            pytest.param(2, 3, [3, 2], AirflowFailException, 
id="later_container_over_skip"),
+        ],
+    )
+    @mock.patch.object(EcsBaseOperator, "client")
+    def test_check_success_task_fail_on_exit_code(
+        self, client_mock, fail_codes, skip_codes, exit_codes, expected
+    ):
+        self.set_up_operator(fail_on_exit_code=fail_codes, 
skip_on_exit_code=skip_codes)
+        self.ecs.arn = "arn"
+        containers = [
+            {"lastStatus": "STOPPED", **({"exitCode": code} if code is not 
None else {})}
+            for code in exit_codes
+        ]
+        client_mock.describe_tasks.return_value = {"tasks": [{"containers": 
containers}]}
+
+        with pytest.raises(expected) as raised:
+            self.ecs._check_success_task()
+
+        assert type(raised.value) is expected
+        client_mock.describe_tasks.assert_called_once_with(cluster="c", 
tasks=["arn"])
+
+    @mock.patch.object(EcsBaseOperator, "client")
+    def test_check_success_task_fail_on_exit_code_preserves_log_tail(self, 
client_mock):
+        self.set_up_operator(fail_on_exit_code=2)
+        self.ecs.arn = "arn"
+        self.ecs.task_log_fetcher = mock.Mock(spec=AwsTaskLogFetcher)
+        self.ecs.task_log_fetcher.get_last_log_messages.return_value = 
["COPY_PROBE_123"]
+        client_mock.describe_tasks.return_value = {
+            "tasks": [{"containers": [{"lastStatus": "STOPPED", "exitCode": 
2}]}]
+        }
+
+        with pytest.raises(AirflowFailException, match="COPY_PROBE_123"):
+            self.ecs._check_success_task()
+
+        
self.ecs.task_log_fetcher.get_last_log_messages.assert_called_once_with(
+            self.ecs.number_logs_exception
+        )
+
     @pytest.mark.parametrize(
         ("launch_type", "tags"),
         [

Reply via email to