Feature: AIP-105 Pluggable Retry Policies Target release: Airflow 3.3.0 (core) + apache-airflow-providers-common-ai (LLM backend) AIP: https://cwiki.apache.org/confluence/display/AIRFLOW/AIP-105%3A+Pluggable+Retry+Policies Author: Kaxil Naik


Run all tests using breeze from main against SQLite (default), PostgreSQL, and MySQL.

Note on existing unit coverage  these layers are already covered in isolation; the cases below focus on migration, full-stack/integration, serialization round-trips, Cadwyn compat, and the LLM backend:


How the pieces fit together

A retry policy is an object you attach to a task with retry_policy=.... When the task fails, the policy's evaluate(exception, try_number, max_tries, context) runs in the worker process (it has the live exception object) and returns a RetryDecision:

RetryActionEffect
RETRYRetry — optionally with a custom retry_delay and a reason. Still bounded by the task's retries; once retries are exhausted it behaves as DEFAULT.
FAILFail immediately, skipping any remaining retries.
DEFAULTFall through to standard retry-count / exponential-backoff logic.

Two backends ship:

  1. ExceptionRetryPolicy (core, airflow.sdk) — declarative RetryRule list, first match wins. No AI dependency.
  2. LLMRetryPolicy (apache-airflow-providers-common-ai) — classifies the error with an LLM via PydanticAIHook, with a declarative fallback_rules safety net.

Decision flow back to the scheduler: the worker never writes scheduling state directly. A RETRY decision is carried in the RetryTask Execution-API payload as retry_delay_seconds + retry_reason; the API route writes them to two TI columns, retry_delay_override (Float) and retry_reason (String(500)). TaskInstance.next_retry_datetime() reads retry_delay_override and lets it take precedence over the static delay / exponential backoff. The columns are cleared when the TI next enters RUNNING (ti_run), and a per-try snapshot is preserved in task_instance_history.

Serialization: the policy object itself is not serialized into the DAG. Only a has_retry_policy: bool flag is stored (mirroring has_on_retry_callback). The worker re-parses the DAG file and reads the live ti.task.retry_policy. The scheduler never instantiates or runs the policy.

Reference QA DAG (drop into breeze; exercises F-* below deterministically)


from __future__ import annotations from datetime import timedelta from airflow.sdk import DAG, ExceptionRetryPolicy, RetryAction, RetryRule, task QA_POLICY = ExceptionRetryPolicy( rules=[ RetryRule( exception=PermissionError, action=RetryAction.FAIL, reason="Auth failure, not retryable", ), RetryRule( exception=ConnectionError, action=RetryAction.RETRY, retry_delay=timedelta(seconds=30), reason="Transient, backing off 30s", ), # list form + dotted-string path, sharing one behaviour RetryRule( exception=[TimeoutError, "builtins.OSError"], action=RetryAction.RETRY, retry_delay=timedelta(seconds=10), ), ], default=RetryAction.DEFAULT, ) with DAG("aip105_retry_policy_qa", schedule=None, catchup=False, tags=["qa", "retry_policy"]): @task(retries=3, retry_delay=timedelta(minutes=5), retry_policy=QA_POLICY) def fail_fast_on_auth(): raise PermissionError("403 Forbidden") # FAIL → no retry despite retries=3 @task(retries=3, retry_delay=timedelta(minutes=5), retry_policy=QA_POLICY) def retry_with_override(): raise ConnectionError("connection reset") # RETRY → 30s override, not 5m @task(retries=3, retry_delay=timedelta(minutes=5), retry_policy=QA_POLICY) def default_path(): raise ValueError("unmatched error") # no rule → DEFAULT → standard 5m retry @task(retries=0, retry_policy=QA_POLICY) def retry_exhausted(): raise ConnectionError("no retries left") # RETRY but retries=0 → behaves as DEFAULT → FAILED fail_fast_on_auth() retry_with_override() default_path() retry_exhausted()

1. Migration testing

Migration 0113_3_3_0_add_retry_policy_fields_to_ti.py (revision b8f3e4a1d2c9, down-revision fde9ed84d07b) adds two nullable columns  retry_delay_override (Float) and retry_reason (String(500))  to both task_instance and task_instance_history.

IDTest CaseExpected ResultPass CriteriaExecution Result
M-01 [P0] [DB required]Fresh 3.3 install creates the columnstask_instance and task_instance_history each have retry_delay_override and retry_reason.Both columns present on both tables, nullable, correct types (FLOAT / VARCHAR(500)).✅ Pass
Live DB: both tables have both columns — retry_delay_override = double precision (FLOAT), nullable; retry_reason = varchar(500), nullable.
M-02 [P0] [DB required]Upgrade from a populated 3.2.x DBMigration completes; existing TI rows untouched; the two new columns are NULL on all existing rows.Spot-check 5 pre-existing task_instance rows intact; new columns NULL.✅ Pass

M-03 [P0] [DB required]Schema correctness after upgradeColumns nullable with no default; adding them is metadata-only on PG/MySQL 8+ (no table rewrite).Assertions pass on SQLite, PostgreSQL, MySQL.✅ Pass
Schema correctness (PostgreSQL): both columns is_nullable=YES, no default (column_default NULL), correct types on both tables.

M-04 [P0] [DB required]Clean downgrade 3.3 → 3.2 drops both columns from both tablesretry_reason then retry_delay_override dropped; other columns intact.Both columns gone from task_instance and task_instance_history; spot-check TI rows otherwise intact.✅ Pass
airflow db downgrade -r fde9ed84d07b ran b8f3e4a1d2c9 -> fde9ed84d07b; both columns dropped from both tables; a pre-existing task_instance row survived intact (state, try_number preserved).
M-05 [P1] [DB required]Upgrade → populate overrides → downgrade → re-upgrade roundtripRe-upgrade succeeds; columns re-created (override values dropped on downgrade).No migration errors on either pass.✅ Pass
Re-airflow db migrate ran fde9ed84d07b -> b8f3e4a1d2c9; both columns re-created on both tables (double precision / varchar(500), nullable, no default); no migration errors on either pass.

2. Functional — ExceptionRetryPolicy (declarative backend)

Full-stack via a running worker. Trigger aip105_retry_policy_qa (reference DAG above) unless noted.

IDTest CaseExpected ResultPass CriteriaExecution Result
F-01 [P0]FAIL action fails immediately, skipping remaining retriesfail_fast_on_auth (raises PermissionError, retries=3) ends FAILED on attempt 1.TI FAILED, try_number == 1; no UP_FOR_RETRY transition.✅ Pass
fail_fast_on_auth FAILED try=1; decision action=fail, reason "Auth failure, not retryable"
F-02 [P0]RETRY action with custom delay overrides the static retry_delayretry_with_override (raises ConnectionError) goes UP_FOR_RETRY; next attempt scheduled ~30s out, not 5m.next_retry_datetime() ≈ end_date + 30s; retry_delay_override = 30.0 on the live row.✅ Pass
retry_with_override UP_FOR_RETRY; decision action=retry, reason "Transient, backing off 30s"
F-03 [P0]No matching rule → DEFAULT → standard retry behaviourdefault_path (raises ValueError, no rule) retries on the static 5m delay.UP_FOR_RETRY; retry_delay_override IS NULL; next retry ~5m out.✅ Pass
default_path UP_FOR_RETRY (standard), no decision reason (DEFAULT)
F-04 [P0]RETRY once retries are exhausted behaves as DEFAULT (terminal FAIL)retry_exhausted (retries=0, ConnectionError → RETRY rule) ends FAILED, not UP_FOR_RETRY.TI FAILED; no retry attempt scheduled.✅ Pass
retry_exhausted (retries=0) FAILED try=1; decision action=retry logged but terminal-fail (behaves as DEFAULT)
F-05 [P0]reason is logged and persisted on the TIAfter F-02, the policy reason appears in the task log and on the row.Task log shows the decision reason; retry_reason = "Transient, backing off 30s".✅ Pass
structured Retry policy decision event: action=retry, reason="Transient, backing off 30s"
F-06 [P1]First-matching-rule-wins orderingAdd a DAG where two rules match the same exception; only the first applies.Behaviour matches the first listed rule.✅ Pass
precedence_check UP_FOR_RETRY, reason "Matched rule for ConnectionError" (RETRY rule beats later Exception→FAIL)
F-07 [P1]List-of-exceptions rule matches any memberA task raising OSError matches the [TimeoutError, "builtins.OSError"] rule.RETRY with 10s override; retry_delay_override = 10.0.✅ Pass
timeout_match/connection_match/oserror_match + retry_on_oserror (FileNotFoundError) all RETRY
F-08 [P1]Dotted-string exception path resolves on the workerA rule with exception="requests.exceptions.HTTPError" matches a real HTTPError.Rule matches; decision applied.✅ Pass
f08_dotted_path_match (raises requests.exceptions.HTTPError) → RETRY, reason "F-08 dotted path resolved"
F-09 [P1]match_subclasses=False requires an exact typeRule on ConnectionError with match_subclasses=False; raise a subclass (e.g. ConnectionResetError).Subclass does NOT match; falls through to default.✅ Pass
exact ConnectionError→RETRY; ConnectionResetError (subclass)→no match→default FAIL
F-10 [P1]Invalid dotted-string path is caught at DAG-parse timeRetryRule(exception="not_a_path") (no dot).ValueError at parse: "must be a dotted import path".✅ Pass
importError: ValueError: RetryRule exception string must be a dotted import path ... got 'not_a_path'
F-11 [P1]Unresolvable-but-well-formed path warns at parse, never matchesRetryRule(exception="some.module.DoesNotExist").DAG parses (warning logged); rule never matches at runtime → default.✅ Pass
DAG parses; f11_unresolvable_never_matches never matches → default FAIL
F-12 [P0]A broken evaluate() never crashes the taskCustom policy whose evaluate raises; attach to a failing task.Exception swallowed (logged), decision treated as None → standard retry logic runs.✅ Pass
f12_n01_broken_evaluate UP_FOR_RETRY (standard); evaluate() exception swallowed, task not crashed

3. Functional — fail-fast exceptions that bypass the policy

The policy is consulted for AirflowException, AirflowTaskTimeout, AirflowRuntimeError, SystemExit, and generic BaseException. It is never consulted for AirflowFailException, AirflowSensorTimeout, or AirflowTaskTerminated  those always fail immediately.

IDTest CaseExpected ResultPass CriteriaExecution Result
FF-01 [P0]AirflowFailException fails immediately even with a RETRY rule for itTask with a RetryRule(exception=AirflowFailException, action=RETRY) raises it.TI FAILED on attempt 1; policy not consulted (no decision reason in log).✅ Pass
fail_immediately FAILED try=1, no decision log (policy not consulted)
FF-02 [P1]AirflowSensorTimeout bypasses the policyReschedule-mode sensor hits timeout with a policy attached.TI FAILED; no retry; policy not consulted.✅ Pass
ff02_sensor_timeout_bypasses FAILED try=1, no decision log
FF-03 [P1]AirflowTaskTimeout does go through the policyTask with execution_timeout and a RETRY-everything policy times out.Policy consulted; RETRY decision honoured (distinguishes it from the bypass set).✅ Pass
with exception=BaseException rule: ff03_execution_timeout, ff03_raise_tasktimeout_directly, and control all RETRY with 60s override + decision log (gap ≈60s, not the task's 10s).

4. Delay-override & reason persistence lifecycle

IDTest CaseExpected ResultPass CriteriaExecution Result
L-01 [P0] [DB required]retry_delay_override is written to the live TI row on RETRYAfter F-02, query the row.task_instance.retry_delay_override = 30.0, retry_reason set.✅ Pass
L-02 [P0] [DB required]next_retry_datetime() uses the override over exponential backoffTask has retry_exponential_backoff=True AND a 30s override.Next retry = end_date + 30s, not the backoff value.✅ Pass
L-03 [P0] [DB required]Override columns are cleared when the TI re-enters RUNNINGLet the retry start running.retry_delay_override and retry_reason reset to NULL on the live row at ti_run.✅ Pass
L-04 [P1] [DB required]Per-try audit trail preserved in task_instance_historyAfter a RETRY then a subsequent run, inspect history.The historical try row retains its retry_delay_override / retry_reason.✅ Pass
L-05 [P1] [DB required]retry_reason longer than 500 chars is truncated, not rejectedPolicy returns a >500-char reason.Stored value is exactly 500 chars; no DB error / overflow.✅ Pass

5. Custom RetryPolicy subclasses & mapped tasks

IDTest CaseExpected ResultPass CriteriaExecution Result
CP-01 [P1]A user-defined RetryPolicy subclass works end-to-endSubclass RetryPolicy, implement evaluate, attach to a task.Decision honoured by the worker.✅ Pass
cp01_custom_subclass RETRY, reason "CP-01 custom subclass decision"
CP-02 [P1]context is forwarded to evaluatePolicy reads context["ti"] / context["task_instance"].Context available and correct (not None) for a normally-run task.✅ Pass
reason "CP-02 context ti.task_id=cp02_context_forwarded try=1" (context not None)
CP-03 [P0]retry_policy carries through .partial(...).expand(...)Mapped task built with retry_policy= in partial; one mapped instance fails.Every mapped TI evaluates the policy; has_retry_policy true on the mapped operator.✅ Pass
all 3 mapped TIs RETRY, reason "CP-03 mapped RETRY" (policy carries through mapping)

6. Serialization

The policy object is not serialized; only has_retry_policy: bool is stored. The worker re-parses the DAG to obtain the live policy.

IDTest CaseExpected ResultPass CriteriaExecution Result
SR-01 [P0] [DB required]has_retry_policy is true in the serialized DAG when a policy is setParse + serialize the reference DAG; inspect the serialized blob.Serialized operator carries has_retry_policy: true; the policy object itself is absent.✅ Pass
SR-02 [P0] [DB required]A task with NO policy serializes has_retry_policy false / unsetSerialize a plain task.No policy artefact; standard retry behaviour preserved.✅ Pass
SR-03 [P0] [breeze required]Worker honours the policy from the re-parsed DAG, not the serialized blobRun the reference DAG through scheduler → worker.F-01…F-05 outcomes hold via the full pipeline (proves the re-parse path works).✅ Pass
SR-04 [P1]ExceptionRetryPolicy.serialize() / deserialize() round-trip preserves behaviourRound-trip the policy object; re-evaluate the same exception.Same RetryDecision (action, delay, reason, match_subclasses) before and after.✅ Pass

7. LLM backend — LLMRetryPolicy (apache-airflow-providers-common-ai)

Requires Airflow 3.3+ and apache-airflow-providers-common-ai[<llm-extra>]. Example DAG: example_llm_retry_policy.py. For deterministic CI runs, mock PydanticAIHook; for an E2E pass, use a local model (Ollama) or a low-cost hosted model.

Connection setup (per the example): pydanticai_default, conn_type='pydanticai', password=<API key>, extra='{"model": "<provider:model>"}'.

IDTest CaseExpected ResultPass CriteriaExecution Result
AI-01 [P0]Auth-style error classified should_retry=False → FAILtask_auth_error (raises PermissionError("403 …")).TI FAILED immediately; retry_reason carries auth: <reasoning>.✅ Pass
ai01_auth_error (PermissionError 403) → FAILED try=1 (skipped retries=3), decision fail, reason "auth: invalid or missing credentials".
AI-02 [P0]Rate-limit error → RETRY with a suggested delaytask_rate_limit (raises RuntimeError("429 …")).UP_FOR_RETRY; retry_delay_override ≈ suggested delay (e.g. 60s).✅ Pass
ai02_rate_limit (RuntimeError 429) → UP_FOR_RETRY, retry_delay_override=60, decision retry, reason "rate_limit: API throttled, back off 60s".
AI-03 [P1]Data error classified non-retryable → FAILtask_data_error (raises ValueError("…type INT but got STRING…")).TI FAILED; reason data: <reasoning>.✅ Pass
ai03_data_error (ValueError type mismatch) → FAILED try=1, decision fail, reason "data: schema/type mismatch, not retryable"
AI-04 [P0]LLM call failure falls back to fallback_rulesBreak the connection (bad creds / unreachable); task raises ConnectionError.Fallback RetryRule applies (RETRY 10s); no task crash.✅ Pass
ai04_llm_fail_fallback (llm_conn_id="broken_llm" → classify raises) → fallback_rules applied: UP_FOR_RETRY, retry_delay_override=10, reason "fallback: transient network"; no crash.
AI-05 [P0]LLM failure with no fallback_rules  DEFAULTSame outage, policy constructed without fallback_rules.Standard retry logic runs; no crash.✅ Pass
ai05_llm_fail_default (broken LLM, no fallback) → DEFAULT (standard retry, override=NULL, no decision log); no crash.
AI-06 [P1]timeout bounds the decision pathSet timeout=1.0 against a slow/degraded provider.Falls back within ~timeout; does not block on the provider's own (longer) timeout.✅ Pass
LLMRetryPolicy(llm_conn_id="ollama_llm", timeout=1.0, fallback_rules=…); the CPU model can't answer in 1s, so classify timed out and fell back: "LLM retry classification failed, using fallback" → decision retry / "fallback: LLM exceeded timeout", override=10, attempt duration 6.3s (bounded — did not block on the model's full ~30s inference).
AI-07 [P1]should_retry=True with suggested_delay_seconds<=0 → RETRY with no overrideClassification returns retry + delay 0.UP_FOR_RETRY; retry_delay_override IS NULL (uses task default).✅ Pass
ai07_retry_no_delay (should_retry=True, suggested_delay=0) → RETRY with override=NULL (task default delay), reason "transient: …".
AI-08 [P1]Importing LLMRetryPolicy on Airflow < 3.3 raises a clear errorInstall provider against a 3.2 core.ImportError naming "requires Airflow 3.3+"; example DAG self-skips (no parse crash).✅ Pass
Import guard present: retry.py:18-21  except ImportError: raise ImportError("LLMRetryPolicy requires Airflow 3.3+ … Please upgrade apache-airflow-core."). Cannot execute the <3.3 import on a 3.3 Breeze, but the clear error + message are verified.
AI-09 [P1]Local-LLM (Ollama) path works for data-residency usePoint the connection at a local Ollama model.Classification + decision work without leaving the cluster.✅ Pass
Local llama3.2:3b via Ollama (ollama_llm conn → http://localhost:11434/v1, openai:llama3.2:3b), no mock. ai09_auth_error → LLM category=auth, should_retry=False  FAIL ("auth: …"); ai09_rate_limit → LLM category=rate_limit, should_retry=True, delay=60s  RETRY ("rate_limit: API throttling or quota exceeded"). Real classification (not fallback); data never leaves the box.

8. Cadwyn / API version compatibility

Execution-API version v2026_06_16 (AddRetryPolicyFields) adds retry_delay_seconds and retry_reason to TIRetryStatePayload.

IDTest CaseStepsExpected ResultPass CriteriaExecution Result
C-01 [P0] [DB required] [breeze required]Old Task SDK (pre-3.3) against a 3.3 server runs normallyInstall 3.3 server; run a worker with a pre-AIP-105 SDK; trigger a plain failing task (no policy).Standard retry/fail works; the new payload fields are simply absent.TI behaves exactly as 3.2; no retry_delay_seconds errors in logs.✅ Pass
On the old-version (2026-04-06) worker: legacy_etl_task (plain, no policy, retries=1) → up_for_retry → running → failed, try_number=2, retry_delay_override/retry_reason NULL, no retry_delay_seconds/validation errors in the log — identical to pre-3.3. policy_task_old_client (has a policy) → failed try_number=3, retried via standard delay, retry_delay_override=NULL, and the retry state-update was accepted gracefully (no 422) — old client neither sends nor is broken by the new fields.
C-02 [P1] [DB required]New fields are stripped when serving an older API versionRequest TIRetryStatePayload handling under a pre-2026_06_16 version.retry_delay_seconds / retry_reason removed from the older-version schema.Older client neither sees nor must send the fields.✅ Pass
Under 2026-04-06, policy_task_old_client sent a TIRetryStatePayload whose new fields were dropped server-side (override never reached the DB; NULL) with no error — i.e. the server strips retry_delay_seconds/retry_reason for the older version, exactly as AddRetryPolicyFields (v2026_06_30.py:84-85) declares. (Note: /execution/openapi.json is NOT a valid instrument here — CadwynWithOpenAPICustomization.customize_openapi re-injects head component schemas into every version's doc, cadwyn #255, so the doc shows the fields at all versions.)
C-03 [P1] [DB required] [breeze required]New SDK + new server round-trips the overrideRun the reference DAG end-to-end on matching 3.3 versions.RetryTask(retry_delay_seconds=30, retry_reason=...) reaches the DB columns.L-01 holds via the real wire path.✅ Pass
Covered by L-01/L-02 — matching new SDK + new server writes retry_delay_override=30 to task_instance and it drives the next-retry timing.

9. Safety / negative cases

IDTest CaseExpected ResultPass CriteriaExecution Result
N-01 [P0]Scheduler never instantiates or runs the policyParse a DAG whose policy object would error if evaluate were called server-side.Scheduler healthy; policy only ever runs on the worker.✅ Pass
broken/failing policies never affected scheduler; only worker evaluates (all runs scheduled fine)
N-02 [P1]A non-callable / malformed retry_policy is rejected at definition timePass a non-RetryPolicy object.Clear error at task definition / parse, not at runtime.(error)  Fail
malformed retry_policy is NOT rejected at definition time (discrepancy)
N-03 [P1]Backwards compatibility: no retry_policy == pre-3.3 behaviourRun existing DAGs unchanged on 3.3.Identical retry behaviour; columns stay NULL.✅ Pass
no_policy UP_FOR_RETRY via standard retries, no decision log

10. Documentation review

IDTest CasePass CriteriaExecution Result
D-01 [P1]Core docs cover the featureairflow-core/docs/core-concepts/tasks.rst documents retry_policy, ExceptionRetryPolicy, RetryRule, RetryAction with a working example.✅ Pass
D-02 [P1]Provider docs cover the LLM backendproviders/common/ai/docs/retry_policies.rst documents LLMRetryPolicy, connection setup, fallback_rules, timeout, and the local-LLM path.✅ Pass
D-03 [P1]Example DAGs parse and are discoverableexample_retry_policy and example_llm_retry_policy load without error (the latter self-skips < 3.3).✅ Pass
  • No labels