pankajastro commented on code in PR #74719:
URL: https://github.com/apache/airflow/pull/74719#discussion_r4283076138
##########
airflow-core/docs/core-concepts/tasks.rst:
##########
@@ -254,14 +256,83 @@ Composition with existing parameters
* - Parameter
- Behaviour when ``retry_policy`` is set
* - ``retries``
- - Still the maximum retry count. Policy can fail earlier but not exceed
it.
+ - Still the maximum retry count. Policy can fail earlier but not exceed
it. A
+ reschedule does not use a retry.
* - ``retry_delay`` / ``retry_exponential_backoff`` / ``max_retry_delay``
- Used when the policy returns DEFAULT or when
``RetryDecision.retry_delay`` is None.
+ A reschedule without its own delay waits ``retry_delay``, without
backoff.
* - ``on_retry_callback``
- - Fires on all retries, including policy-driven retries.
+ - Fires on all retries, including policy-driven retries. Does not fire on
a reschedule.
Review Comment:
The page says `on_retry_callback` does not fire on a reschedule.
`finalize()` also skips `on_task_instance_failed` listeners and
`email_on_retry` for `UP_FOR_RESCHEDULE`, and `ti_failures`/`operator_failures`
aren't emitted. Is that intended, and should the docs list it, since a policy
reschedule follows a raised exception? Is there a test that pins "callback not
called"?
---
Drafted-by: Claude Code (Sonnet 5.5); reviewed by @pankajastro before posting
##########
airflow-core/docs/core-concepts/tasks.rst:
##########
@@ -254,14 +256,83 @@ Composition with existing parameters
* - Parameter
- Behaviour when ``retry_policy`` is set
* - ``retries``
- - Still the maximum retry count. Policy can fail earlier but not exceed
it.
+ - Still the maximum retry count. Policy can fail earlier but not exceed
it. A
+ reschedule does not use a retry.
* - ``retry_delay`` / ``retry_exponential_backoff`` / ``max_retry_delay``
- Used when the policy returns DEFAULT or when
``RetryDecision.retry_delay`` is None.
+ A reschedule without its own delay waits ``retry_delay``, without
backoff.
* - ``on_retry_callback``
- - Fires on all retries, including policy-driven retries.
+ - Fires on all retries, including policy-driven retries. Does not fire on
a reschedule.
* - ``AirflowFailException``
- Always takes precedence. The policy is never consulted for this
exception.
+.. _concepts:retry-policies-reschedule:
+
+Rescheduling without using a retry
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.5.0
+
+Some failures mean "not yet" rather than "this went wrong": the system the
task drives is
+out of capacity, such as a Kubernetes namespace whose resource quota is full
or a warehouse
+with no free slots. Retrying spends the task's ``retries`` on waiting, so a
real failure
+later has none left, and ``on_retry_callback`` fires every time.
+
+**RESCHEDULE** puts the task in ``up_for_reschedule`` instead, the same state
a sensor in
+``mode="reschedule"`` uses between pokes. The try number and ``retries`` are
untouched and
+``on_retry_callback`` does not fire. It works with ``retries=0``.
+
+.. exampleinclude:: /../src/airflow/example_dags/example_retry_policy.py
+ :language: python
+ :start-after: [START retry_policy_reschedule]
+ :end-before: [END retry_policy_reschedule]
+
+A RESCHEDULE always has a bound, so a rule matching a broad exception cannot
loop forever:
+
+* ``max_reschedules`` -- how many times the try may be rescheduled.
+* ``reschedule_timeout`` -- how long after the first reschedule the try may
keep being
+ rescheduled.
+
+Set either or both; a RESCHEDULE without one raises ``ValueError`` when the
rule or decision
+is created. Both count every reschedule of this try, including a sensor's
pokes, so a retry
Review Comment:
"raises `ValueError` when the rule or decision is created": inside a custom
`evaluate`, that happens at failure time. `_evaluate_retry_policy` catches it
and falls back to standard retry logic with only a log. Should the docs say
that?
---
Drafted-by: Claude Code (Sonnet 5.5); reviewed by @pankajastro before posting
##########
airflow-core/docs/core-concepts/tasks.rst:
##########
@@ -254,14 +256,83 @@ Composition with existing parameters
* - Parameter
- Behaviour when ``retry_policy`` is set
* - ``retries``
- - Still the maximum retry count. Policy can fail earlier but not exceed
it.
+ - Still the maximum retry count. Policy can fail earlier but not exceed
it. A
Review Comment:
Could these be stated positively?
- "A reschedule does not use a retry" -> "A reschedule leaves the retry
count unchanged".
- "Does not fire on a reschedule" (L265) -> "Fires on retries only".
- "appears only in the task log" (L300) -> "appears in the task log".
- "...not for a disruption of the Airflow worker itself" (L303-305) -> "Use
it when the external system is not ready; worker disruptions follow the normal
retry path."
---
Drafted-by: Claude Code (Sonnet 5.5); reviewed by @pankajastro before posting
##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -1956,9 +1960,54 @@ def _handle_current_task_failed(
return _finalize_task_failure(
ti, log, retry_delay_override=decision.retry_delay,
retry_reason=decision.reason
)
+ if decision is not None and decision.action == RetryAction.RESCHEDULE:
+ if (limit := _reschedule_limit_reached(ti, decision, context)) is None:
+ ti.end_date = datetime.now(tz=UTC)
+ delay = decision.retry_delay if decision.retry_delay is not None
else ti.task.retry_delay
+ reschedule_date = ti.end_date + delay
+ log.info(
+ "Retry policy rescheduled the task, marking task as
UP_FOR_RESCHEDULE",
+ reschedule_date=reschedule_date,
+ reschedule_count=_reschedule_count(ti),
+ max_reschedules=decision.max_reschedules,
+ reschedule_timeout=decision.reschedule_timeout,
+ )
+ return (
+ RescheduleTask(reschedule_date=reschedule_date,
end_date=ti.end_date),
+ TaskInstanceState.UP_FOR_RESCHEDULE,
+ )
+ log.info("Reschedule limit reached, applying the standard retry
logic", limit=limit)
+ reason = f"Reschedule limit reached ({limit})"
+ if decision.reason:
+ reason = f"{reason}: {decision.reason}"
+ return _finalize_task_failure(ti, log, retry_reason=reason)
return _finalize_task_failure(ti, log)
+def _reschedule_count(ti: RuntimeTaskInstance) -> int:
+ """Return how many times this try has been rescheduled so far."""
+ from_server = ti._ti_context_from_server
+ return (from_server.task_reschedule_count or 0) if from_server else 0
+
+
+def _reschedule_limit_reached(
+ ti: RuntimeTaskInstance, decision: RetryDecision, context: Context | None
+) -> str | None:
+ """
+ Say which bound stops a RESCHEDULE decision, or return None when the task
may reschedule.
+
+ Both bounds count every reschedule of this try, whatever caused it (a
sensor's pokes and a
+ startup reschedule included), so a retry starts them afresh.
+ """
+ if decision.max_reschedules is not None and _reschedule_count(ti) >=
decision.max_reschedules:
+ return f"max_reschedules={decision.max_reschedules}"
+ if decision.reschedule_timeout is not None and context is not None:
Review Comment:
`reschedule_timeout` is only checked when `context is not None`. Every
current caller passes a context, but `_handle_current_task_failed(...,
context=None)` allows `None`. Should `context` be required, or should `None`
fall back to the standard retry logic, so a timeout-only rule can never become
unbounded?
---
Drafted-by: Claude Code (Sonnet 5.5); reviewed by @pankajastro before posting
##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -1703,6 +1703,170 @@ def execute(self, context):
)
+class _CapacityError(Exception):
+ """Stands in for a cluster rejecting a pod because its resource quota is
full."""
+
+
+class _NoCapacityTask(BaseOperator):
+ def execute(self, context):
+ raise _CapacityError("exceeded quota")
+
+
+def _reschedule_policy(retry_delay: timedelta | None = timedelta(minutes=10),
**bounds):
+ return ExceptionRetryPolicy(
+ rules=[
+ RetryRule(
+ exception=_CapacityError,
+ action=RetryAction.RESCHEDULE,
+ retry_delay=retry_delay,
+ reason="cluster full",
+ **bounds,
+ )
+ ]
+ )
+
+
[email protected](
+ ("bounds", "task_reschedule_count", "first_rescheduled_ago"),
+ [
+ pytest.param({"max_reschedules": 3}, 0, timedelta(0),
id="first-failure"),
+ pytest.param({"max_reschedules": 3}, 2, timedelta(minutes=20),
id="below-max-reschedules"),
+ pytest.param(
+ {"reschedule_timeout": timedelta(hours=1)}, 2,
timedelta(minutes=20), id="within-timeout"
+ ),
+ ],
+)
+def test_retry_policy_reschedule_does_not_use_a_retry(
+ time_machine,
+ create_runtime_ti,
+ mock_supervisor_comms,
+ bounds,
+ task_reschedule_count,
+ first_rescheduled_ago,
+):
+ """RESCHEDULE within its bounds reschedules the try, even with no retries
configured."""
+ now = datetime(2026, 10, 14, 9, 0, tzinfo=UTC)
+ time_machine.move_to(now, tick=False)
+ task = _NoCapacityTask(task_id="no_capacity", retries=0,
retry_policy=_reschedule_policy(**bounds))
+ ti = create_runtime_ti(task=task,
task_reschedule_count=task_reschedule_count)
+ ti._ti_context_from_server.first_task_reschedule_start_date = now -
first_rescheduled_ago
+
+ state, msg, error = run(ti, ti.get_template_context(),
structlog.get_logger())
+
+ assert state == TaskInstanceState.UP_FOR_RESCHEDULE
+ assert msg == RescheduleTask(reschedule_date=now + timedelta(minutes=10),
end_date=now)
+
+
+def test_retry_policy_reschedule_without_delay_uses_the_task_retry_delay(
+ time_machine, create_runtime_ti, mock_supervisor_comms
+):
+ now = datetime(2026, 10, 14, 9, 0, tzinfo=UTC)
+ time_machine.move_to(now, tick=False)
+ task = _NoCapacityTask(
+ task_id="no_capacity",
+ retry_delay=timedelta(minutes=7),
+ retry_policy=_reschedule_policy(retry_delay=None, max_reschedules=3),
+ )
+ ti = create_runtime_ti(task=task)
+
+ state, msg, error = run(ti, ti.get_template_context(),
structlog.get_logger())
+
+ assert state == TaskInstanceState.UP_FOR_RESCHEDULE
+ assert msg.reschedule_date == now + timedelta(minutes=7)
+
+
[email protected](
+ ("bounds", "task_reschedule_count", "first_rescheduled_ago", "limit"),
+ [
+ pytest.param(
+ {"max_reschedules": 3}, 3, timedelta(minutes=30),
"max_reschedules=3", id="max-reschedules"
+ ),
+ pytest.param(
+ {"reschedule_timeout": timedelta(hours=1)},
+ 5,
+ timedelta(hours=2),
+ "reschedule_timeout=1:00:00",
+ id="reschedule-timeout",
+ ),
+ ],
+)
[email protected](
+ ("retries", "expected_state", "expected_msg"),
+ [
+ pytest.param(
+ 1,
+ TaskInstanceState.UP_FOR_RETRY,
+ lambda now, reason: RetryTask(end_date=now, retry_reason=reason),
+ id="retry-left",
+ ),
+ pytest.param(
+ 0,
+ TaskInstanceState.FAILED,
+ lambda now, reason: TaskState(state=TaskInstanceState.FAILED,
end_date=now, retry_reason=reason),
+ id="no-retry-left",
+ ),
+ ],
+)
+def
test_retry_policy_reschedule_past_its_bound_applies_the_standard_retry_logic(
+ time_machine,
+ create_runtime_ti,
+ mock_supervisor_comms,
+ bounds,
+ task_reschedule_count,
+ first_rescheduled_ago,
+ limit,
+ retries,
+ expected_state,
+ expected_msg,
+):
+ """Past its bound the task retries on its own retry_delay, not the
reschedule delay, or fails."""
+ now = datetime(2026, 10, 14, 9, 0, tzinfo=UTC)
+ time_machine.move_to(now, tick=False)
+ task = _NoCapacityTask(task_id="no_capacity", retries=retries,
retry_policy=_reschedule_policy(**bounds))
+ ti = create_runtime_ti(task=task,
task_reschedule_count=task_reschedule_count)
+ ti._ti_context_from_server.first_task_reschedule_start_date = now -
first_rescheduled_ago
+
+ state, msg, error = run(ti, ti.get_template_context(),
structlog.get_logger())
+
+ assert state == expected_state
+ assert msg == expected_msg(now, f"Reschedule limit reached ({limit}):
cluster full")
+
+
+class _SensorStyleReschedule(BaseOperator):
+ def execute(self, context):
+ raise AirflowRescheduleException(reschedule_date=datetime(2026, 10,
14, 10, 0, tzinfo=UTC))
+
+
[email protected](
+ ("task", "should_fail_closed"),
+ [
+ pytest.param(
+ _NoCapacityTask(task_id="no_capacity",
retry_policy=_reschedule_policy(max_reschedules=3)),
+ True,
+ id="policy-reschedule-after-an-exception",
+ ),
+ pytest.param(_SensorStyleReschedule(task_id="poke"), False,
id="task-raised-reschedule"),
+ ],
+)
+def test_run_fails_closed_when_a_policy_reschedule_cannot_be_reported(
Review Comment:
No test covers `context=None` or `finalize()` for a policy reschedule. Could
one test assert `on_retry_callback` isn't called and no failure metric is
emitted?
---
Drafted-by: Claude Code (Sonnet 5.5); reviewed by @pankajastro before posting
##########
providers/common/compat/src/airflow/providers/common/compat/_retry_policy.py:
##########
@@ -123,7 +124,7 @@ def evaluate(
reason = f"{name}: {decision.reason or decision.action.value}"
if trail:
reason = f"{reason} (after {'; '.join(trail)})"
- return RetryDecision(action=decision.action,
retry_delay=decision.retry_delay, reason=reason)
+ return replace(decision, reason=reason)
Review Comment:
The compat `ChainRetryPolicy` copy changed to `replace(decision,
reason=reason)`, but `test__retry_policy.py` wasn't touched. Could one test
check that a RESCHEDULE decision keeps its bounds through the copy (skipped
when `RetryAction` has no RESCHEDULE)?
---
Drafted-by: Claude Code (Sonnet 5.5); reviewed by @pankajastro before posting
--
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]