Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions task-sdk/newsfragments/60172.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``AirflowFailException`` raised inside ``on_retry_callback`` now correctly prevents task retries and marks the task as failed.
40 changes: 34 additions & 6 deletions task-sdk/src/airflow/sdk/execution_time/task_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1355,7 +1355,7 @@ def _on_term(signum, frame):
# Same metric with tagging
Stats.incr("ti.finish", tags={**stats_tags, "state": state.value})

if msg:
if msg and state != TaskInstanceState.UP_FOR_RETRY:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it is a good idea to handle the case here?
Rather than rely on a different method called elsewhere, as to me, it seems like this is an undocumented assumption that it will be handled later, at the very least I would add a comment clarifying it, preferably I would explicitly check if the state is up for retry AND there is message and handle it right there (can be extracted to a method but should be obvious where the handling occurs)

SUPERVISOR_COMMS.send(msg=msg)

# Return the message to make unit tests easier too
Expand Down Expand Up @@ -1538,10 +1538,14 @@ def _run_task_state_change_callbacks(
context: Context,
log: Logger,
) -> None:
from airflow.sdk.exceptions import AirflowFailException

Comment on lines +1541 to +1542

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AirflowFailException is already imported at module scope (and is used in multiple places in this file), so importing it inside _run_task_state_change_callbacks adds an unnecessary local import and goes against the project guideline to keep imports at module level unless there’s a clear reason (circular import / isolation / lazy load). Consider moving this to the existing module-level from airflow.sdk.exceptions import (...) import list and reusing it here.

Suggested change
from airflow.sdk.exceptions import AirflowFailException

Copilot uses AI. Check for mistakes.
callback: Callable[[Context], None]
for i, callback in enumerate(getattr(task, kind)):
try:
create_executable_runner(callback, context_get_outlet_events(context), logger=log).run(context)
except AirflowFailException:
raise
except Exception:
log.exception("Failed to run task callback", kind=kind, index=i, callback=callback)

Expand Down Expand Up @@ -1747,6 +1751,7 @@ def finalize(
context: Context,
log: Logger,
error: BaseException | None = None,
msg: ToSupervisor | None = None,
):
# Record task duration metrics for all terminal states
if ti.start_date and ti.end_date:
Expand Down Expand Up @@ -1801,15 +1806,38 @@ def finalize(
except Exception:
log.exception("error calling listener")
elif state == TaskInstanceState.UP_FOR_RETRY:
_run_task_state_change_callbacks(task, "on_retry_callback", context, log)
from airflow.sdk.exceptions import AirflowFailException

try:
_run_task_state_change_callbacks(task, "on_retry_callback", context, log)
Comment on lines 1808 to +1812

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to _run_task_state_change_callbacks, this local import of AirflowFailException is unnecessary (it’s already available at module scope in this file) and adds repeated imports in a hot path. Prefer using the existing module-level import unless there’s a specific circular-import / isolation reason.

Copilot uses AI. Check for mistakes.
except AirflowFailException as e:
# If on_retry_callback raises AirflowFailException, the task should not retry.
log.info("on_retry_callback raised AirflowFailException; marking task as failed without retry")
state = TaskInstanceState.FAILED
ti.state = state
error = e
SUPERVISOR_COMMS.send(
msg=TaskState(
state=TaskInstanceState.FAILED,
end_date=ti.end_date,
rendered_map_index=ti.rendered_map_index,
)
)
_run_task_state_change_callbacks(task, "on_failure_callback", context, log)
if error and task.email_on_failure and task.email:
_send_error_email_notification(task, ti, context, error, log)
else:
# No AirflowFailException: proceed with retry as originally planned.
if msg:
SUPERVISOR_COMMS.send(msg=msg)
if error and task.email_on_retry and task.email:
_send_error_email_notification(task, ti, context, error, log)
Comment on lines +1833 to +1834

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this handled by the code deleted below either way?

try:
get_listener_manager().hook.on_task_instance_failed(
previous_state=TaskInstanceState.RUNNING, task_instance=ti, error=error
)
except Exception:
log.exception("error calling listener")
if error and task.email_on_retry and task.email:
_send_error_email_notification(task, ti, context, error, log)
elif state == TaskInstanceState.FAILED:
_run_task_state_change_callbacks(task, "on_failure_callback", context, log)
try:
Expand Down Expand Up @@ -1871,9 +1899,9 @@ def main():
bundle_name=ti.bundle_instance.name,
bundle_version=ti.bundle_instance.version,
):
state, _, error = run(ti, context, log)
state, run_msg, error = run(ti, context, log)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The msg was originally returned for testing, I would suggest either removing the comment, or understand if we should use it in the finalize or non test code (I still think it is better to handle in the run as much as possible)

context["exception"] = error
finalize(ti, state, context, log, error)
finalize(ti, state, context, log, error, msg=run_msg)
except KeyboardInterrupt:
log.exception("Ctrl-c hit")
sys.exit(2)
Expand Down
47 changes: 47 additions & 0 deletions task-sdk/tests/task_sdk/execution_time/test_task_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4360,6 +4360,53 @@ class CustomOperator(BaseOperator):
expected_exception_logs.insert(index, calls)
assert log.exception.mock_calls == expected_exception_logs

def test_airflow_fail_exception_in_on_retry_callback_marks_task_as_failed(
self, create_runtime_ti, mock_supervisor_comms
):
"""AirflowFailException raised in on_retry_callback must prevent retries and mark task as FAILED."""
from airflow.sdk.exceptions import AirflowFailException

Comment on lines +4367 to +4368

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AirflowFailException is already imported at the top of this test module, so this additional in-test import is redundant. Removing it will reduce noise and keep imports consistent within the file.

Suggested change
from airflow.sdk.exceptions import AirflowFailException

Copilot uses AI. Check for mistakes.
failure_callback_called = []
retry_callback_called = []

def retry_callback(context):
retry_callback_called.append(True)
raise AirflowFailException("No more retries!")
Comment on lines +4369 to +4374

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use mocks and side effects instead of manually counting call counts


def failure_callback(context):
failure_callback_called.append(True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above


class FailingOperator(BaseOperator):
def execute(self, context):
raise AirflowException("Task failed")

task = FailingOperator(
task_id="task",
on_retry_callback=retry_callback,
on_failure_callback=failure_callback,
)
runtime_ti = create_runtime_ti(dag_id="dag", task=task, should_retry=True)
log = mock.MagicMock()

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test introduces an unspecced mock.MagicMock() for log. The repo’s testing guidelines prefer mocks with spec/autospec to avoid silently accepting invalid attributes (e.g. mock.MagicMock(spec=FilteringBoundLogger) or whichever logger interface run() expects).

Suggested change
log = mock.MagicMock()
log = mock.MagicMock(spec=type(runtime_ti.log))

Copilot uses AI. Check for mistakes.
context = runtime_ti.get_template_context()
state, run_msg, error = run(runtime_ti, context, log)
finalize(runtime_ti, state, context, log, error, msg=run_msg)

assert runtime_ti.state == TaskInstanceState.FAILED
assert retry_callback_called == [True]
assert failure_callback_called == [True]

# The supervisor must receive TaskState(FAILED), not RetryTask
sent_msgs = [
call.args[0] if call.args else call.kwargs.get("msg")
for call in mock_supervisor_comms.send.call_args_list
]
from airflow.sdk.execution_time.comms import RetryTask, TaskState

assert not any(isinstance(m, RetryTask) for m in sent_msgs), (
"RetryTask must not be sent when on_retry_callback raises AirflowFailException"
)
assert any(isinstance(m, TaskState) and m.state == TaskInstanceState.FAILED for m in sent_msgs)


class TestTriggerDagRunOperator:
"""Tests to verify various aspects of TriggerDagRunOperator"""
Expand Down
Loading