This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 61f933354d1 Add how-to for Dag-level retry via on_failure_callback 
(#66277)
61f933354d1 is described below

commit 61f933354d1259213577f70b0cc242e82b37a5cc
Author: Yuseok Jo <[email protected]>
AuthorDate: Sun May 10 11:10:24 2026 +0900

    Add how-to for Dag-level retry via on_failure_callback (#66277)
---
 .../docs/howto/dag-level-retry-via-callback.rst    | 228 +++++++++++++++++++++
 airflow-core/docs/howto/index.rst                  |   1 +
 2 files changed, 229 insertions(+)

diff --git a/airflow-core/docs/howto/dag-level-retry-via-callback.rst 
b/airflow-core/docs/howto/dag-level-retry-via-callback.rst
new file mode 100644
index 00000000000..d9f469964d0
--- /dev/null
+++ b/airflow-core/docs/howto/dag-level-retry-via-callback.rst
@@ -0,0 +1,228 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+ ..   http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+
+
+Dag-level Retry via on_failure_callback
+=======================================
+
+Airflow's built-in retry mechanism operates at the **task** level: each task 
has its own
+``retries`` and ``retry_delay``, and only that single task is re-run on 
failure.
+
+Tasks should ideally be designed to be **idempotent at the task level** so 
that the
+built-in per-task retry mechanism is sufficient. That is the recommended 
starting point,
+and most workflows should not need anything beyond it.
+
+This how-to is for the cases where, even with task-level idempotency in place, 
you still
+want a coarser unit of retry — effectively a *Dag-level retry*. For example, a 
multi-step
+pipeline whose intermediate state is hard to clean up partially, so re-running 
from the
+beginning is safer than re-running a single failed task.
+
+It shows a pattern that approximates Dag-level retry by combining the
+:doc:`on_failure_callback 
</administration-and-deployment/logging-monitoring/callbacks>`
+with the public Airflow REST API: when a task fails, the callback calls the
+Clear endpoint to clear the failed Dag run, so the scheduler will re-run it.
+
+This is a **recipe** built on top of existing primitives, not a new feature. It
+is opinionated and has trade-offs; read :ref:`Caveats <clear-retry-caveats>`
+carefully before using it.
+
+A more general construct for this — sometimes referred to as a
+*transactional task group* — is being discussed as a possible additional
+feature on the Airflow dev list. If this would benefit your use case, please
+join that discussion rather than rely on this recipe long-term.
+
+
+When this pattern fits
+----------------------
+
+Use it when:
+
+- Your unit of work is naturally a Dag run, not a single task.
+- Re-running tasks is **idempotent** — running them again does not produce
+  duplicate side effects, double-charges, or inconsistent external state.
+- A bounded number of full re-runs is acceptable in terms of time and cost.
+
+Avoid it when:
+
+- Tasks have non-idempotent side effects (sending emails, charging payments,
+  posting to non-deduplicated APIs) and you have not made them idempotent.
+- The retry should be transparent to downstream Dags or assets — clearing a Dag
+  run produces a fresh attempt, not a retry of the original.
+- The simple per-task ``retries`` setting already covers the failure modes you
+  see in practice.
+
+
+How it works
+------------
+
+1. A task fails after exhausting its task-level retries.
+2. The task's ``on_failure_callback`` runs in the dag processor.
+3. The callback decides whether another Dag-level attempt is allowed (using a
+   counter you maintain — see :ref:`Limiting the number of retries
+   <clear-retry-limiting>` below).
+4. If yes, the callback calls the Airflow REST API to clear the failed Dag run.
+5. The scheduler picks up the cleared task instances and re-runs them.
+
+The REST endpoint used is ``POST 
/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/clear``.
+
+
+Prerequisites
+-------------
+
+- Authenticated access to the Airflow REST API from the dag processor, with 
permission
+  to clear Dag runs on the target Dag.
+- Network reachability from the dag processor to the API server.
+
+.. note::
+
+   ``on_failure_callback`` only fires when the failure occurs through normal
+   task execution. State changes performed manually (UI, CLI) do **not** invoke
+   it.
+
+
+Basic example: clear the failed Dag run
+---------------------------------------
+
+The following example clears the entire Dag run when any task in it fails after
+exhausting its task-level retries. ``retries=0`` is set on the task so that the
+callback fires on the first failure and the *whole Dag run* takes over the
+retry responsibility.
+
+This minimal version always clears on failure. **Do not run it as-is in
+production** — it can loop forever if a task keeps failing. Add an attempt
+limit before deploying; see :ref:`Limiting the number of retries
+<clear-retry-limiting>` below.
+
+.. code-block:: python
+
+    from urllib.parse import quote
+
+    import requests
+
+    from airflow.sdk import DAG, task
+
+    AIRFLOW_API_BASE = "https://airflow.example.com/api/v2";  # your 
deployment's API base
+
+
+    def clear_dag_run_on_failure(context):
+        dag_run = context["dag_run"]
+        dag_id_path = quote(dag_run.dag_id, safe="")
+        run_id_path = quote(dag_run.run_id, safe="")
+        response = requests.post(
+            
f"{AIRFLOW_API_BASE}/dags/{dag_id_path}/dagRuns/{run_id_path}/clear",
+            headers={"Authorization": "Bearer <token>"},
+            json={"dry_run": False},
+            timeout=30,
+        )
+        response.raise_for_status()
+
+
+    with DAG(
+        dag_id="example_dag_level_retry",
+        default_args={
+            "retries": 0,  # let the Dag-level retry take over
+            "on_failure_callback": clear_dag_run_on_failure,
+        },
+    ):
+
+        @task
+        def step_one(): ...
+
+        @task
+        def step_two(value): ...
+
+        step_two(step_one())
+
+
+.. _clear-retry-limiting:
+
+Limiting the number of retries
+------------------------------
+
+A naive callback that always clears the run will loop forever if a task keeps
+failing. ``dag_run.conf`` is set when the run is created and is *not* mutated
+by clearing, so it cannot be used as a retry counter — you must track attempts
+yourself. A simple approach is a Variable scoped to the run:
+
+.. code-block:: python
+
+    from airflow.sdk import Variable
+
+
+    def _attempts_key(dag_id: str, run_id: str) -> str:
+        return f"dag_run_attempts::{dag_id}::{run_id}"
+
+
+    def _read_attempts(dag_id: str, run_id: str) -> int:
+        return int(Variable.get(_attempts_key(dag_id, run_id), default=0))
+
+
+    def _bump_attempts(dag_id: str, run_id: str) -> int:
+        new_value = _read_attempts(dag_id, run_id) + 1
+        Variable.set(_attempts_key(dag_id, run_id), str(new_value))
+        return new_value
+
+Use ``_read_attempts`` before deciding to clear, then ``_bump_attempts`` right
+before calling the API. Reset the counter when the Dag run finishes — both on
+success and on give-up — so the Variables do not accumulate:
+
+.. code-block:: python
+
+    def cleanup_attempts(context):
+        dag_run = context["dag_run"]
+        Variable.delete(_attempts_key(dag_run.dag_id, dag_run.run_id))
+
+Wire ``cleanup_attempts`` to ``on_success_callback`` at the Dag level, and call
+it from the give-up branch of your ``on_failure_callback`` before returning.
+
+.. note::
+
+   This Variable-based counter is **not** atomic across concurrent callbacks.
+   If your Dag has parallel branches that may all fail at once, two callbacks
+   could each read the same value and both decide to clear. In practice the
+   second clear is a no-op (the first one already reset the run), but you may
+   see duplicate API calls. For stricter guarantees, use a backend with
+   compare-and-swap semantics.
+
+
+.. _clear-retry-caveats:
+
+Caveats
+-------
+
+- **``UP_FOR_RETRY`` wait between Dag-level retries.** A failed task waits in
+  ``UP_FOR_RETRY`` for ``retry_delay`` (default 5 minutes) before transitioning
+  to ``FAILED`` and triggering the next clear, so each Dag-level retry is
+  delayed by at least that interval.
+- **Idempotency is your responsibility.** Every cleared task will run again.
+  Anything that produced an external side effect on the failed attempt — a
+  row written, a message sent, a file uploaded — will be produced again
+  unless your task is idempotent (e.g. uses upserts, deduplication keys, or
+  conditional writes).
+- **Loop hazard.** A bug in the counter or in the failing task can cause an
+  infinite clear-and-retry loop. Always cap the attempts and alert on the cap
+  being hit.
+- **Cost.** A Dag-level retry re-runs every cleared task from scratch. Make
+  sure the time and resource cost is acceptable, and keep the attempt cap low.
+- **Running tasks.** If your Dag has parallel branches, one task can fail while
+  other tasks in the same Dag run are still running. Design the Dag and its
+  side effects so that clearing the run while other work is in progress is 
safe.
+- **Token lifetime.** Tokens issued by most auth managers expire (e.g. the
+  simple auth manager's JWT defaults to 24 hours). A static token stored in a
+  Variable will silently 401 after expiration; the loop guard prevents bad
+  behavior but Dag-level retry stops working. For long-running deployments,
+  refresh the credential periodically or use a longer-lived service credential.
diff --git a/airflow-core/docs/howto/index.rst 
b/airflow-core/docs/howto/index.rst
index 17d56694a87..519d230342f 100644
--- a/airflow-core/docs/howto/index.rst
+++ b/airflow-core/docs/howto/index.rst
@@ -36,6 +36,7 @@ configuring an Airflow environment.
     notifications
     sla-to-deadlines
     deadline-alerts
+    dag-level-retry-via-callback
     set-config
     set-up-database
     operator/index

Reply via email to