Fix task callbacks being skipped when TriggerDagRunOperator gets a 404 - #70719
Conversation
e93282b to
e67f071
Compare
`run()` maps a task's outcome through a flat chain of `except` clauses, and several of those clauses do real work: they call the API server, or serialize user-supplied values. Python does not offer an exception raised inside an `except` clause to that clause's siblings, so when one of them raised, the exception escaped `run()` entirely -- skipping the retry decision in `_handle_current_task_failed()` and every callback, listener and failure email in `finalize()`. The reported path: triggering a Dag that does not exist returns 404, which `DagRunOperations.trigger` re-raises (it only special-cases the 409 already-exists case). The supervisor turns it into an `API_SERVER_ERROR` response and `CommsDecoder._from_frame` raises `AirflowRuntimeError` -- from inside `except DagRunTriggerException`, a few lines above the clause that already handles `AirflowRuntimeError`. It is not limited to that path: `_defer_task` and `_await_input_task` run `serde_serialize` over user-supplied kwargs, which raises `TypeError` for any value serde has no serializer for. Split the function at the point where deciding the outcome ends and reporting it begins. `_run_task_and_map_outcome()` keeps the chain verbatim and returns the outcome; `run()` calls it, and its `except` now covers the handlers too. The chain and the terminal-state `finally` block are untouched, so this is a behaviour change rather than a reshuffle of existing lines. `_handle_handler_failure()` keeps the chain's own classifications instead of routing everything through the retry-count check, so `AirflowFailException`, `AirflowSensorTimeout` and `AirflowTaskTerminated` still fail without retrying when they surface from a handler. It catches `Exception`, not `BaseException`, so `KeyboardInterrupt` still reaches `main()`'s exit-code-2 path -- the supervisor's default termination signal is SIGINT, so swallowing it would turn an operator-initiated kill into an ordinary retry. If the failure path itself raises, it fails closed on a plain FAILED state rather than re-entering the code that just failed and escaping again.
e67f071 to
c4ff229
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes a Task SDK task-runner control-flow bug where exceptions raised inside run()’s exception handlers could escape the function entirely, skipping retry decisions and all failure/finalization callbacks (notably affecting TriggerDagRunOperator when the Execution API returns 404, and deferral paths when kwargs are not serializable).
Changes:
- Refactors the task execution flow by extracting the existing exception-mapping chain into
_run_task_and_map_outcome()and wrapping it inrun()so handler-thrown exceptions still flow through the same terminal-state reporting andfinalize()path. - Adds
_handle_handler_failure()/_terminal_failure()to preserve “do not retry” semantics for non-retryable exceptions and to “fail closed” if the failure path itself errors. - Prevents double-counting failure metrics when the failure path runs twice due to an internal error.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| task-sdk/src/airflow/sdk/execution_time/task_runner.py | Restructures run() to catch handler failures, adds handler-failure outcome mapping, and guards failure metrics from double emission. |
| task-sdk/tests/task_sdk/execution_time/test_task_runner.py | Adds regression tests for retries/callbacks on handler failures (missing Dag 404 path and unserializable deferral kwargs), plus coverage for edge cases (non-retryable, KeyboardInterrupt, failure-path failure). |
Comments suppressed due to low confidence (1)
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:5419
- Test docstring includes a GitHub issue URL/number. Airflow guidelines recommend not embedding issue numbers in test docstrings; keep the regression explanation but remove the issue reference.
Regression test for https://github.com/apache/airflow/issues/70683 -- the
``AirflowRuntimeError`` was raised from inside ``run()``'s
``except DagRunTriggerException`` handler, so it escaped ``run()`` without
evaluating retries or running ``on_failure_callback`` / ``on_retry_callback``.
"""
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Backport successfully created: v3-3-testNote: As of Merging PRs targeted for Airflow 3.X In matter of doubt please ask in #release-management Slack channel.
|
…tor` gets a 404 (#70719) (#71083) * Fix task callbacks being skipped when TriggerDagRunOperator gets a 404 `run()` maps a task's outcome through a flat chain of `except` clauses, and several of those clauses do real work: they call the API server, or serialize user-supplied values. Python does not offer an exception raised inside an `except` clause to that clause's siblings, so when one of them raised, the exception escaped `run()` entirely -- skipping the retry decision in `_handle_current_task_failed()` and every callback, listener and failure email in `finalize()`. The reported path: triggering a Dag that does not exist returns 404, which `DagRunOperations.trigger` re-raises (it only special-cases the 409 already-exists case). The supervisor turns it into an `API_SERVER_ERROR` response and `CommsDecoder._from_frame` raises `AirflowRuntimeError` -- from inside `except DagRunTriggerException`, a few lines above the clause that already handles `AirflowRuntimeError`. It is not limited to that path: `_defer_task` and `_await_input_task` run `serde_serialize` over user-supplied kwargs, which raises `TypeError` for any value serde has no serializer for. Split the function at the point where deciding the outcome ends and reporting it begins. `_run_task_and_map_outcome()` keeps the chain verbatim and returns the outcome; `run()` calls it, and its `except` now covers the handlers too. The chain and the terminal-state `finally` block are untouched, so this is a behaviour change rather than a reshuffle of existing lines. `_handle_handler_failure()` keeps the chain's own classifications instead of routing everything through the retry-count check, so `AirflowFailException`, `AirflowSensorTimeout` and `AirflowTaskTerminated` still fail without retrying when they surface from a handler. It catches `Exception`, not `BaseException`, so `KeyboardInterrupt` still reaches `main()`'s exit-code-2 path -- the supervisor's default termination signal is SIGINT, so swallowing it would turn an operator-initiated kill into an ordinary retry. If the failure path itself raises, it fails closed on a plain FAILED state rather than re-entering the code that just failed and escaping again. * Potential fix for pull request finding --------- (cherry picked from commit f0c13dc) Co-authored-by: Kaxil Naik <kaxilnaik@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Fixes #70683
Problem
When
TriggerDagRunOperatortargets a Dag that does not exist, the task dies without runningon_failure_callback/on_retry_callback, without firing task-instance listeners, and without evaluating the task'sretry_policy. To the user the task just goes red, and none of their failure handling runs.The cause is not specific to
TriggerDagRunOperator.run()maps the task's outcome through a flat chain ofexceptclauses, and several of those clauses do real work. An exception raised inside anexceptclause is not offered to that clause's siblings, so it escapesrun()entirely, skipping the retry decision in_handle_current_task_failed()and everything infinalize().For the reported case the chain is:
dag_runs.py#L107).DagRunOperations.triggeronly special-cases 409, so the 404 re-raises (client.py#L927).API_SERVER_ERRORresponse (supervisor.py#L945).CommsDecoder._from_frameraisesAirflowRuntimeError(comms.py#L359).That raise happens inside
except DagRunTriggerException, 39 lines above the clause that already handlesAirflowRuntimeError. Python never consults it.It is not limited to the API server.
_defer_taskand_await_input_taskrunserde_serializeover user-supplied kwargs, and serde raises for any value it has no serializer for:So
self.defer(..., kwargs={"client": some_hook_client})loses its callbacks the same way, with no 404 anywhere in sight. A file handle, a generator or a lambda all behave identically.Solution
Split the function where deciding the outcome ends and reporting it begins:
_run_task_and_map_outcome()holds the existing chain verbatim and returns(state, msg, error).run()calls it, and itsexceptnow covers the handlers as well as the task body, flowing into the same terminal-statefinallyas before.The handler chain and the
finallyblock are not edited, and nothing is reindented:git diffandgit diff -ware identical, so there is no whitespace churn to read past.Catching the handlers is deliberately structural rather than a list of the four that can raise today. This chain has grown handlers over time (HITL,
DagRunTrigger), which is how the defect arose in the first place; a fifth risky handler should not have to remember to opt in.Preserving the chain's own semantics
A catch-all boundary must not flatten the distinctions the chain already makes, so
_handle_handler_failure():AirflowFailException,AirflowSensorTimeoutandAirflowTaskTerminatedmean "do not retry" wherever they are raised. Routing them through the retry-count check would hand backUP_FOR_RETRY.Exception, notBaseException. AKeyboardInterruptstill reachesmain()'s exit-code-2 path. The supervisor's default termination signal is SIGINT, escalating SIGINT → SIGTERM → SIGKILL, so swallowing it would turn an operator-initiated kill into an ordinary retry.RetryDecisionis an unvalidated dataclass, so aretry_policyreturningretry_delay=30rather than atimedeltamakes_finalize_task_failureraiseAttributeErroron.total_seconds(), outside_evaluate_retry_policy's own error handling. Re-entering that path with its own exception would fail the same way and escape, losing the callbacks this change exists to preserve, so it falls back to a plainFAILEDstate. The reportederroris the exception that actually ended the run, so a callback sees the broken policy; the task's own failure stays reachable on the implicit exception chain.That path also runs
_finalize_task_failuretwice, so the failure counters are now emitted once per task run rather than once per pass. Without that, a single failure would reportti_failures=2.What changes for users
On
maintoday the supervisor's exit-code fallback already recoversUP_FOR_RETRYfrom the non-zero exit, so the task does still retry. What was being lost, and now works:on_failure_callbackandon_retry_callbackrunon_task_instance_failedlisteners fireemail_on_failure/email_on_retryare sentretry_policyis evaluated, so it can forceFAILor supply a custom delay and reasonRetryTaskmessage carriesend_date, retry delay and retry reason instead of being skippedti_failures,operator_failuresandti.finishstats are recordeddag.test()andrun_task_in_processno longer crash out of the CLI, sinceInProcessTestSupervisor.startcallsrun()unguardedThe issue reports retries dropped entirely, which was accurate for 3.1.0: the
_should_retryexit-code fallback landed later in #55767 and first shipped in 3.1.2.Gotchas
If the first trigger attempt partially succeeds (the Dag run is created, then a later call in the same handler fails), the task is now retried where it previously died. On retry
TriggerDagRunOperatormints a run id fromrun_after or utcnow()when no explicittrigger_run_idis set, so that retry can create a second Dag run. This behaviour is unchanged by this PR: the supervisor's exit-code fallback already produced the same retry onmain. Pinningtrigger_run_idmakes the retry idempotent via the existing 409 handling.