DUE TO SPAM, SIGN-UP IS DISABLED. Goto Selfserve wiki signup and request an account.
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
mainagainst 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:
task-sdk/.../definitions/test_retry_policy.py—RetryRulematching (class / dotted-string / list /match_subclasses),ExceptionRetryPolicyfirst-match-wins, serialize/deserialize round-trips,_evaluate_retry_policy/_handle_current_task_failed(FAIL bypasses count, RETRY with delay+reason, reason truncation, retries-exhausted → DEFAULT).task-sdk/.../execution_time/test_task_runner.py— worker failure routing.providers/common/ai/.../policies/test_retry.py—LLMRetryPolicyclassify decisions and fallback behaviour.airflow-core/.../models/test_taskinstance.py,test_serialized_objects.py,test_dag_serialization.py,execution_api/versions/head/test_task_instances.py— TI columns, serialization, Execution API payload.
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:
RetryAction | Effect |
|---|---|
RETRY | Retry — optionally with a custom retry_delay and a reason. Still bounded by the task's retries; once retries are exhausted it behaves as DEFAULT. |
FAIL | Fail immediately, skipping any remaining retries. |
DEFAULT | Fall through to standard retry-count / exponential-backoff logic. |
Two backends ship:
ExceptionRetryPolicy(core,airflow.sdk) — declarativeRetryRulelist, first match wins. No AI dependency.LLMRetryPolicy(apache-airflow-providers-common-ai) — classifies the error with an LLM viaPydanticAIHook, with a declarativefallback_rulessafety 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.
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
M-01 [P0] [DB required] | Fresh 3.3 install creates the columns | task_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 DB | Migration 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 upgrade | Columns 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 tables | retry_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. | ✅ Passairflow 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 roundtrip | Re-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.
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
F-01 [P0] | FAIL action fails immediately, skipping remaining retries | fail_fast_on_auth (raises PermissionError, retries=3) ends FAILED on attempt 1. | TI FAILED, try_number == 1; no UP_FOR_RETRY transition. | ✅ Passfail_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_delay | retry_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. | ✅ Passretry_with_override UP_FOR_RETRY; decision action=retry, reason "Transient, backing off 30s" |
F-03 [P0] | No matching rule → DEFAULT → standard retry behaviour | default_path (raises ValueError, no rule) retries on the static 5m delay. | UP_FOR_RETRY; retry_delay_override IS NULL; next retry ~5m out. | ✅ Passdefault_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. | ✅ Passretry_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 TI | After 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 ordering | Add a DAG where two rules match the same exception; only the first applies. | Behaviour matches the first listed rule. | ✅ Passprecedence_check UP_FOR_RETRY, reason "Matched rule for ConnectionError" (RETRY rule beats later Exception→FAIL) |
F-07 [P1] | List-of-exceptions rule matches any member | A task raising OSError matches the [TimeoutError, "builtins.OSError"] rule. | RETRY with 10s override; retry_delay_override = 10.0. | ✅ Passtimeout_match/connection_match/oserror_match + retry_on_oserror (FileNotFoundError) all RETRY |
F-08 [P1] | Dotted-string exception path resolves on the worker | A rule with exception="requests.exceptions.HTTPError" matches a real HTTPError. | Rule matches; decision applied. | ✅ Passf08_dotted_path_match (raises requests.exceptions.HTTPError) → RETRY, reason "F-08 dotted path resolved" |
F-09 [P1] | match_subclasses=False requires an exact type | Rule 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 time | RetryRule(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 matches | RetryRule(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 task | Custom policy whose evaluate raises; attach to a failing task. | Exception swallowed (logged), decision treated as None → standard retry logic runs. | ✅ Passf12_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.
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
FF-01 [P0] | AirflowFailException fails immediately even with a RETRY rule for it | Task with a RetryRule(exception=AirflowFailException, action=RETRY) raises it. | TI FAILED on attempt 1; policy not consulted (no decision reason in log). | ✅ Passfail_immediately FAILED try=1, no decision log (policy not consulted) |
FF-02 [P1] | AirflowSensorTimeout bypasses the policy | Reschedule-mode sensor hits timeout with a policy attached. | TI FAILED; no retry; policy not consulted. | ✅ Passff02_sensor_timeout_bypasses FAILED try=1, no decision log |
FF-03 [P1] | AirflowTaskTimeout does go through the policy | Task 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
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
L-01 [P0] [DB required] | retry_delay_override is written to the live TI row on RETRY | After 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 backoff | Task 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 RUNNING | Let 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_history | After 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 rejected | Policy returns a >500-char reason. | Stored value is exactly 500 chars; no DB error / overflow. | ✅ Pass |
5. Custom RetryPolicy subclasses & mapped tasks
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
CP-01 [P1] | A user-defined RetryPolicy subclass works end-to-end | Subclass RetryPolicy, implement evaluate, attach to a task. | Decision honoured by the worker. | ✅ Passcp01_custom_subclass RETRY, reason "CP-01 custom subclass decision" |
CP-02 [P1] | context is forwarded to evaluate | Policy 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.
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
SR-01 [P0] [DB required] | has_retry_policy is true in the serialized DAG when a policy is set | Parse + 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 / unset | Serialize 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 blob | Run 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 behaviour | Round-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, mockPydanticAIHook; 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>"}'.
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
AI-01 [P0] | Auth-style error classified should_retry=False → FAIL | task_auth_error (raises PermissionError("403 …")). | TI FAILED immediately; retry_reason carries auth: <reasoning>. | ✅ Passai01_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 delay | task_rate_limit (raises RuntimeError("429 …")). | UP_FOR_RETRY; retry_delay_override ≈ suggested delay (e.g. 60s). | ✅ Passai02_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 → FAIL | task_data_error (raises ValueError("…type INT but got STRING…")). | TI FAILED; reason data: <reasoning>. | ✅ Passai03_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_rules | Break the connection (bad creds / unreachable); task raises ConnectionError. | Fallback RetryRule applies (RETRY 10s); no task crash. | ✅ Passai04_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 → DEFAULT | Same outage, policy constructed without fallback_rules. | Standard retry logic runs; no crash. | ✅ Passai05_llm_fail_default (broken LLM, no fallback) → DEFAULT (standard retry, override=NULL, no decision log); no crash. |
AI-06 [P1] | timeout bounds the decision path | Set timeout=1.0 against a slow/degraded provider. | Falls back within ~timeout; does not block on the provider's own (longer) timeout. | ✅ PassLLMRetryPolicy(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 override | Classification returns retry + delay 0. | UP_FOR_RETRY; retry_delay_override IS NULL (uses task default). | ✅ Passai07_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 error | Install 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 use | Point 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.
| ID | Test Case | Steps | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|---|
C-01 [P0] [DB required] [breeze required] | Old Task SDK (pre-3.3) against a 3.3 server runs normally | Install 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 version | Request 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 override | Run 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
| ID | Test Case | Expected Result | Pass Criteria | Execution Result |
|---|---|---|---|---|
N-01 [P0] | Scheduler never instantiates or runs the policy | Parse 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 time | Pass a non-RetryPolicy object. | Clear error at task definition / parse, not at runtime. | malformed retry_policy is NOT rejected at definition time (discrepancy) |
N-03 [P1] | Backwards compatibility: no retry_policy == pre-3.3 behaviour | Run existing DAGs unchanged on 3.3. | Identical retry behaviour; columns stay NULL. | ✅ Passno_policy UP_FOR_RETRY via standard retries, no decision log |
10. Documentation review
| ID | Test Case | Pass Criteria | Execution Result |
|---|---|---|---|
D-01 [P1] | Core docs cover the feature | airflow-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 backend | providers/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 discoverable | example_retry_policy and example_llm_retry_policy load without error (the latter self-skips < 3.3). | ✅ Pass |