kaxil commented on code in PR #73027:
URL: https://github.com/apache/airflow/pull/73027#discussion_r4042333959


##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -1931,9 +1932,17 @@ def _finalize_task_failure(
         if retry_reason is not None:
             retry_kwargs["retry_reason"] = retry_reason[:500]
         return RetryTask(**retry_kwargs), TaskInstanceState.UP_FOR_RETRY
+    if retry_reason is not None and ti._ti_context_from_server is not None:
+        # max_tries is the retry count, not the attempt count -- total 
attempts is max_tries + 1.
+        total_attempts = ti._ti_context_from_server.max_tries + 1
+        suffix = f"; retries exhausted ({ti.try_number} of {total_attempts})"

Review Comment:
   Ran this at head: a task with `retry_policy=` and no `retries=` persists 
`"rate limit; retries exhausted (1 of 1)"`. `[core] default_task_retries` is 0, 
so `max_tries` is 0, `should_retry` is False on the first failure, and this 
branch fires on attempt 1. The count is right and matches core's own "Starting 
attempt 1 of 1", but "exhausted" is the wrong word when no budget ever existed, 
and the thing the user needs to do (set `retries=N`) is exactly what the 
wording hides. Skipping the suffix when `max_tries == 0` covers it.
   
   Worth deciding now whether the worker should bake this sentence into the 
stored value at all, because nothing reads the column yet (`retry_reason` greps 
empty across `core_api/` and `ui/`) and once a UI renders it the format is 
fixed. `max_tries` is cumulative, `ti.max_tries = ti.try_number + task.retries` 
on clear, so after a couple of clears the denominator reads like a retry count 
nobody configured. `try_number > max_tries` is derivable from the same row, so 
a renderer could say this in the viewer's locale and the column could hold just 
the policy's own text.
   
   Either way this branch logs nothing, so the task log's last word is `Retry 
policy decision action=retry` and then FAILED, with nothing saying why the 
retry did not happen. A `log.info` here would close that in the channel that 
works today.



##########
airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py:
##########
@@ -52,3 +52,13 @@ class AddCallbackRunEndpoint(VersionChange):
     instructions_to_migrate_to_previous_version = (
         endpoint("/callbacks/{callback_id}/run", ["PATCH"]).didnt_exist,
     )
+
+
+class AddTerminalStateRetryReasonField(VersionChange):
+    """Add the `retry_reason` field to TITerminalStatePayload for failed 
retry-policy decisions."""
+
+    description = __doc__
+
+    instructions_to_migrate_to_previous_version = (
+        schema(TITerminalStatePayload).field("retry_reason").didnt_exist,

Review Comment:
   Correcting my own ask from last round: I measured this at head and the 
instruction does not gate anything. With `Airflow-API-Version` pinned, a body 
carrying `retry_reason` is accepted at 2025-04-11, 2026-04-06, 2026-06-30 and 
2026-10-30 alike, while a bogus field is rejected at all four with 
`extra_forbidden` on the `_terminal_` union tag. That tag in the error path is 
the tell: `TIStateUpdate` is a discriminated union and cadwyn does not reach 
through it to swap in the versioned copies, so the route keeps validating 
against the head models. The shipped `rendered_map_index` gate at 2025-04-28 is 
inert in exactly the same way, so this is not something you introduced.
   
   I do not think it blocks and I am not asking you to fix cadwyn. Keep the 
class, it is the documented recipe. Two things worth knowing: a client 
generated from a pinned old version inherits a contract claiming `retry_reason` 
existed on `TITerminalStatePayload` back to 2025-04-11, which shipped in 3.0.0, 
and the per-version test that `AGENTS.md` step 4 asks for would fail today if 
you wrote it, which is why this stays invisible. I will file the framework 
issue separately.
   
   This does not generalise to the supervisor half, which I checked separately 
and which does bind.



##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -1196,6 +1196,65 @@ def execute(self, context):
     assert counted.count("operator_failures") == 1
 
 
+def test_retry_policy_fail_persists_reason(create_runtime_ti, 
mock_supervisor_comms):
+    class _AlwaysFails(BaseOperator):
+        def execute(self, context):
+            raise RuntimeError("boom")
+
+    task = _AlwaysFails(
+        task_id="fail_with_reason",
+        retry_policy=ExceptionRetryPolicy(
+            rules=[RetryRule(exception=RuntimeError, action=RetryAction.FAIL, 
reason="do not retry")]
+        ),
+    )
+    ti = create_runtime_ti(task=task, should_retry=True)
+
+    state, msg, error = run(ti, ti.get_template_context(), mock.MagicMock())
+
+    assert state == TaskInstanceState.FAILED
+    assert isinstance(msg, TaskState)
+    assert msg.retry_reason == "do not retry"
+
+
+def 
test_retry_policy_retry_exhausted_persists_combined_reason(create_runtime_ti, 
mock_supervisor_comms):
+    """A policy-chosen RETRY that hits an exhausted budget still fails, with 
both reasons recorded."""
+
+    class _AlwaysFails(BaseOperator):
+        def execute(self, context):
+            raise RuntimeError("boom")
+
+    task = _AlwaysFails(
+        task_id="retry_exhausted",
+        retry_policy=ExceptionRetryPolicy(
+            rules=[RetryRule(exception=RuntimeError, action=RetryAction.RETRY, 
reason="rate limit")]
+        ),
+    )
+    ti = create_runtime_ti(task=task, try_number=3, max_tries=2, 
should_retry=False)
+
+    state, msg, error = run(ti, ti.get_template_context(), mock.MagicMock())
+
+    assert state == TaskInstanceState.FAILED
+    assert isinstance(msg, TaskState)
+    assert msg.retry_reason == "rate limit; retries exhausted (3 of 3)"
+
+
+def test_plain_retries_exhausted_has_no_reason(create_runtime_ti, 
mock_supervisor_comms):
+    """Without a retry policy, exhausting the retry budget must not synthesize 
a reason."""
+
+    class _AlwaysFails(BaseOperator):
+        def execute(self, context):
+            raise RuntimeError("boom")
+
+    task = _AlwaysFails(task_id="plain_exhausted")
+    ti = create_runtime_ti(task=task, try_number=3, max_tries=2, 
should_retry=False)
+
+    state, msg, error = run(ti, ti.get_template_context(), mock.MagicMock())
+
+    assert state == TaskInstanceState.FAILED
+    assert isinstance(msg, TaskState)
+    assert msg.retry_reason is None

Review Comment:
   `RetryAction.RETRY`'s docstring still says "When all retries are exhausted, 
RETRY behaves identically to DEFAULT". This test and the one above it now 
assert those two halves diverging: RETRY-exhausted stores a reason, 
DEFAULT-exhausted stores NULL. Since that docstring is the rendered public docs 
for the enum, worth updating it here. `FAIL` and `DEFAULT`'s docstrings are 
both still accurate.



##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -1196,6 +1196,65 @@ def execute(self, context):
     assert counted.count("operator_failures") == 1
 
 
+def test_retry_policy_fail_persists_reason(create_runtime_ti, 
mock_supervisor_comms):
+    class _AlwaysFails(BaseOperator):
+        def execute(self, context):
+            raise RuntimeError("boom")
+
+    task = _AlwaysFails(
+        task_id="fail_with_reason",
+        retry_policy=ExceptionRetryPolicy(
+            rules=[RetryRule(exception=RuntimeError, action=RetryAction.FAIL, 
reason="do not retry")]
+        ),
+    )
+    ti = create_runtime_ti(task=task, should_retry=True)
+
+    state, msg, error = run(ti, ti.get_template_context(), mock.MagicMock())
+
+    assert state == TaskInstanceState.FAILED
+    assert isinstance(msg, TaskState)
+    assert msg.retry_reason == "do not retry"
+
+
+def 
test_retry_policy_retry_exhausted_persists_combined_reason(create_runtime_ti, 
mock_supervisor_comms):
+    """A policy-chosen RETRY that hits an exhausted budget still fails, with 
both reasons recorded."""
+
+    class _AlwaysFails(BaseOperator):
+        def execute(self, context):
+            raise RuntimeError("boom")
+
+    task = _AlwaysFails(
+        task_id="retry_exhausted",
+        retry_policy=ExceptionRetryPolicy(
+            rules=[RetryRule(exception=RuntimeError, action=RetryAction.RETRY, 
reason="rate limit")]
+        ),
+    )
+    ti = create_runtime_ti(task=task, try_number=3, max_tries=2, 
should_retry=False)
+
+    state, msg, error = run(ti, ti.get_template_context(), mock.MagicMock())
+
+    assert state == TaskInstanceState.FAILED
+    assert isinstance(msg, TaskState)
+    assert msg.retry_reason == "rate limit; retries exhausted (3 of 3)"

Review Comment:
   I reverted last round's truncation back to `retry_reason[:500]` and this 
test plus the other new ones all still passed, so nothing guards the fix. With 
a 600-char reason the old form produces 528 chars, the route cuts it to 500, 
and the suffix is amputated entirely, which is the defect the fix was for. 
Widening the reason here to `"z" * 600` and adding `assert 
len(msg.retry_reason) == 500` plus `assert msg.retry_reason.endswith("; retries 
exhausted (3 of 3)")` closes it. The `endswith` is the half that fails on the 
old code.
   
   Related and cheap in the same pass: `_handle_current_task_failed`'s FAIL 
branch passes `decision.reason` with no bound, so it is the one site that 
leaves truncation entirely to the route. `decision.reason[:500]` there would 
make the rule uniform.



##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -1196,6 +1196,65 @@ def execute(self, context):
     assert counted.count("operator_failures") == 1
 
 
+def test_retry_policy_fail_persists_reason(create_runtime_ti, 
mock_supervisor_comms):
+    class _AlwaysFails(BaseOperator):
+        def execute(self, context):
+            raise RuntimeError("boom")
+
+    task = _AlwaysFails(
+        task_id="fail_with_reason",
+        retry_policy=ExceptionRetryPolicy(
+            rules=[RetryRule(exception=RuntimeError, action=RetryAction.FAIL, 
reason="do not retry")]
+        ),
+    )
+    ti = create_runtime_ti(task=task, should_retry=True)

Review Comment:
   `should_retry=True` with the operator's `retries` left at its default is 
another triple the server will not produce, the same shape as the one from last 
round: server-side it is `max_tries != 0 and try_number <= max_tries`, so with 
`max_tries=0` this can only be False. The assertion still holds, since the FAIL 
branch returns before `should_retry` is read, so it is not false coverage. But 
the fixture reads as "budget available, policy overrides it" without setting 
that up, which is the most valuable thing to pin about `RetryAction.FAIL`. 
`retries=2` on the operator plus `max_tries=2` here would deliver it.
   
   Same idea on 1232 and 1249: adding `retries=2` to the operator lets the 
fixture derive `max_tries` and `should_retry` itself instead of overriding all 
three axes, so they cannot drift back out of the reachable space.



##########
task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py:
##########
@@ -34,3 +35,11 @@ class AddArgBindingsToSupervisorTIRunContext(VersionChange):
     description = __doc__
 
     instructions_to_migrate_to_previous_version = 
(schema(TIRunContext).field("arg_bindings").didnt_exist,)
+
+
+class AddRetryReasonToTaskState(VersionChange):
+    """Add `retry_reason` to `TaskState`."""
+
+    description = __doc__
+
+    instructions_to_migrate_to_previous_version = 
(schema(TaskState).field("retry_reason").didnt_exist,)

Review Comment:
   This gate does work, unlike the execution-API one. I measured it: 
downgrading a head `TaskState` to 2026-06-16 strips `retry_reason`, 2026-10-30 
keeps it, and upgrading a 2026-06-16 body yields `None`, so java-sdk's pin 
stays coherent. It just is not asserted anywhere. 
`AddArgBindingsToSupervisorTIRunContext` right above has 
`TestRealBundleArgBindingsDowngrade` driving the real migrator, and a 
two-assert sibling would pin this one. The upgrade direction is the one foreign 
runtimes are actually on.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -670,7 +670,9 @@ def _create_ti_state_update_query_and_update_state(
         query = query.values(state=updated_state, next_method=None, 
next_kwargs=None)
 
         if updated_state == TaskInstanceState.FAILED:
-            # This is the only case needs extra handling for 
TITerminalStatePayload
+            if isinstance(ti_patch_payload, TITerminalStatePayload) and 
ti_patch_payload.retry_reason:
+                failed_retry_reason: str | None = 
ti_patch_payload.retry_reason[:500]

Review Comment:
   Small one alongside the truncation thread on this line: the `str | None` 
annotation is wider than the guard above it, which already narrows to a truthy 
`str`. Inlining `query = 
query.values(retry_reason=ti_patch_payload.retry_reason[:500])` drops the local 
and the annotation together. The hunk also dropped the `# This is the only case 
needs extra handling for TITerminalStatePayload` comment, which was the one 
line explaining why the branch exists.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -670,7 +670,9 @@ def _create_ti_state_update_query_and_update_state(
         query = query.values(state=updated_state, next_method=None, 
next_kwargs=None)
 
         if updated_state == TaskInstanceState.FAILED:
-            # This is the only case needs extra handling for 
TITerminalStatePayload
+            if isinstance(ti_patch_payload, TITerminalStatePayload) and 
ti_patch_payload.retry_reason:
+                failed_retry_reason: str | None = 
ti_patch_payload.retry_reason[:500]
+                query = query.values(retry_reason=failed_retry_reason)

Review Comment:
   Rebase hazard rather than anything wrong here. 
`providers/common/ai/docs/retry_policies.rst` on main (from #73158, merged 
after you branched) says "``category`` and ``reasoning`` are only recorded on a 
RETRY ... On a FAIL they are not written anywhere". That text is not on your 
branch, so it survives the merge and then contradicts this line, since 
`LLMRetryPolicy` does set the reason on FAIL. Worth updating that paragraph 
after rebasing, including the part about a terminally-failed row keeping the 
value rather than having it cleared when the next attempt starts.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to