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

kaxil 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 64c207d56f8 Document how common.ai agent tasks recover from provider 
and worker failures (#74708)
64c207d56f8 is described below

commit 64c207d56f833225381c0df63ef42f0b06be2427
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 14 12:20:27 2026 +0100

    Document how common.ai agent tasks recover from provider and worker 
failures (#74708)
    
    Add a "Recovering from failures" page that states, per failure, which
    setting handles it and whether completed steps are kept, replayed or run
    again, with a runnable example combining fallback_conn_ids and
    durable=True. Correct the timeout cost of a fallback chain in the
    provider fallback and retry policy pages: the OpenAI and Anthropic SDKs
    retry a timed-out request twice, and a rejected 400 is not retried.
---
 providers/common/ai/docs/operations.rst            |   3 +
 providers/common/ai/docs/provider_fallback.rst     |  22 +--
 providers/common/ai/docs/recovery.rst              | 164 +++++++++++++++++++++
 providers/common/ai/docs/retry_policies.rst        |   5 +-
 .../ai/example_dags/example_agent_recovery.py      |  70 +++++++++
 5 files changed, 253 insertions(+), 11 deletions(-)

diff --git a/providers/common/ai/docs/operations.rst 
b/providers/common/ai/docs/operations.rst
index 9a0ddb28025..0eae34cd5e6 100644
--- a/providers/common/ai/docs/operations.rst
+++ b/providers/common/ai/docs/operations.rst
@@ -22,6 +22,8 @@ Reliability and operations
 
 An AI task fails in more ways than a SQL task, and it costs money each time it 
runs.
 
+- :doc:`recovery` keeps an agent run going through a provider outage, and 
replays the
+  steps it completed when every provider fails or the worker dies.
 - :doc:`durable_execution` replays the model and tool calls an agent already 
completed, so a
   retry pays only for the steps that did not finish.
 - :doc:`retry_policies` lets a model classify a failure and decide whether a 
retry is worth
@@ -38,6 +40,7 @@ An AI task fails in more ways than a SQL task, and it costs 
money each time it r
     :hidden:
     :titlesonly:
 
+    Recovering from failures <recovery>
     Durable execution <durable_execution>
     Retry policies <retry_policies>
     Observability <observability>
diff --git a/providers/common/ai/docs/provider_fallback.rst 
b/providers/common/ai/docs/provider_fallback.rst
index 5e5a3965242..a8a74574b5c 100644
--- a/providers/common/ai/docs/provider_fallback.rst
+++ b/providers/common/ai/docs/provider_fallback.rst
@@ -21,7 +21,9 @@ Provider fallback
 A single ``llm_conn_id`` gives a task one provider. When that provider is 
down, the task
 fails and retries into the same outage. ``fallback_conn_ids`` gives the 
connection an
 ordered list of other connections to try, so a provider outage moves to the 
next vendor
-inside the same task attempt.
+inside the same task attempt. The next vendor receives the run's message 
history and
+continues an agent run from where it stopped; :doc:`recovery` covers that, and 
what
+happens when every connection fails.
 
 Configure it on the connection
 ------------------------------
@@ -146,12 +148,13 @@ Costs to know before configuring a long chain
 ---------------------------------------------
 
 **The timeout multiplies.** pydantic-ai applies a ``ModelSettings`` timeout to 
each model
-in the chain, not to the chain as a whole. A 30-second timeout across three 
connections is
-a 90-second worst case for one call.
+in the chain, not to the chain as a whole, and the OpenAI and Anthropic SDKs 
retry a
+timed-out request twice before giving up on that model. A 30-second timeout 
across three
+such connections is therefore up to 270 seconds, plus backoff, for one call.
 
 **There is no circuit breaker.** Every call tries the primary first. During an 
outage each
-task instance pays the primary's timeout again before failing over, so 500 
mapped tasks pay
-it 500 times. Keeping the primary's timeout short bounds both of these.
+task instance pays the primary's timeouts and retries again before failing 
over, so 500
+mapped tasks pay them 500 times. Keeping the primary's timeout short bounds 
both of these.
 
 **Chains are not resolved recursively.** If a connection listed as a fallback 
declares its
 own ``fallback_conn_ids``, resolution fails with an error rather than 
following it. List
@@ -162,10 +165,11 @@ connection.
 ``ModelAPIError`` family, which includes ``ModelHTTPError`` -- raised for any 
4xx as well as
 5xx. A malformed prompt is the one error every connection in the chain shares: 
the same
 request body goes to each of them, so all reject it alike before the task 
finally sees the
-failure -- N requests, N timeouts, and N billable calls for a request that was 
never going to
-succeed. An expired key does not cost the same way -- it is per-connection, so 
the next
-connection in the chain presents its own credentials and, if they are still 
valid, answers
-normally; that is the chain doing its job, not a repeated failure. A 
misspelled model name is
+failure -- N requests for a request that was never going to succeed. Each 
rejection comes
+back at once, and the OpenAI and Anthropic SDKs do not retry a 400, so the 
cost is the
+requests themselves rather than N timeouts. An expired key does not cost the 
same way -- it
+is per-connection, so the next connection in the chain presents its own 
credentials and, if
+they are still valid, answers normally; that is the chain doing its job, not a 
repeated failure. A misspelled model name is
 shared across the chain only in the narrower case where the name is bare and 
every fallback it
 reaches configures no ``model`` of its own: a bare name that itself embeds a 
``:`` (a vendor's
 native id) only forwards to a fallback on the *same* platform, and a name that 
already pins a
diff --git a/providers/common/ai/docs/recovery.rst 
b/providers/common/ai/docs/recovery.rst
new file mode 100644
index 00000000000..5ccea3db064
--- /dev/null
+++ b/providers/common/ai/docs/recovery.rst
@@ -0,0 +1,164 @@
+ .. 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.
+
+.. _howto/recovery:
+
+Recovering from failures
+========================
+
+An agent task can fail partway through a run: the model provider goes down or 
rate
+limits the task, or the worker running it dies. Two settings keep the work the 
agent
+already did: ``fallback_conn_ids`` moves a failed model request to another 
connection,
+and ``durable=True`` lets an Airflow retry replay the steps an earlier attempt
+completed. To decide whether a failure is worth retrying at all, see
+:doc:`retry_policies`.
+
+What each failure costs
+-----------------------
+
+.. list-table::
+   :header-rows: 1
+   :widths: 30 25 45
+
+   * - Failure
+     - Handled by
+     - What happens to the completed steps
+   * - The provider returns any HTTP 4xx or 5xx error, times out, or cannot be 
reached
+     - ``fallback_conn_ids``, inside the attempt
+     - Kept. The next connection receives the run's message history, including 
every
+       tool result, and continues the run.
+   * - Every connection in the chain fails, and the task raises
+       ``pydantic_ai.exceptions.FallbackExceptionGroup``
+     - Airflow ``retries`` with ``durable=True``
+     - Replayed. The retry replays the steps an earlier attempt recorded, 
whichever
+       connection answered them, and continues on the first connection that 
answers now.
+   * - The worker dies, or the task hits its ``execution_timeout``
+     - Airflow ``retries`` with ``durable=True``
+     - Replayed, as above. A step that was still running runs again.
+   * - Either of the last two, without ``durable=True``
+     - Airflow ``retries``
+     - Run again from the start: every model call is paid for again and every 
tool runs
+       again.
+
+A response the provider does return but pydantic-ai rejects, such as a 
content-filter
+refusal (``ContentFilterError``), does not fail over: the chain moves on only 
for
+``ModelAPIError``.
+
+The OpenAI and Anthropic SDKs retry a connection error, a timeout, and a 408, 
409, 429 or
+5xx response twice by default before the chain sees the failure. A refused 
connection
+therefore costs a few seconds before failover, and a request that hangs costs 
up to three
+times the ``timeout`` in ``model_settings``, plus backoff. Without a 
``timeout``, each
+attempt waits for the SDK's default of 600 seconds. Both SDKs also wait out a 
429's
+``Retry-After`` header before retrying: the OpenAI SDK for up to two minutes, 
the
+Anthropic SDK for as long as the header asks.
+
+On Airflow before 3.3 the journal is a file under ``[common.ai] 
durable_cache_path``; a
+retry that runs on another worker replays only if that path is shared storage. 
See
+:doc:`durable_execution`.
+
+Keep a run going through an outage
+----------------------------------
+
+This task declares both settings: a standby connection for a failed request, 
and a
+journal for a failed attempt:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_agent_recovery.py
+    :language: python
+    :start-after: [START howto_agent_recovery]
+    :end-before: [END howto_agent_recovery]
+
+See :doc:`provider_fallback` for the chain itself, including how a bare model 
name is
+forwarded to connections that set no ``model`` of their own. See
+:doc:`durable_execution` for the journal, including the ``id`` it needs on 
every
+``FunctionToolset``, ``MCPToolset`` and ``DynamicToolset``.
+
+What the next connection receives
+---------------------------------
+
+Failover happens per model request. Each request carries the run's whole 
message
+history: the prompt, every model response so far and every tool result. The 
next
+connection in the chain gets that same history and continues the run; a tool 
that
+already ran is not called again. The next connection can be another vendor's 
model,
+for example an Anthropic model taking over a run an OpenAI model started.
+
+A streamed request is the exception. When the run streams, for example with an
+``event_stream_handler`` in ``agent_params``, the chain moves on only if the 
stream fails
+to open; an error after the stream started propagates and fails the attempt.
+
+To drill the example, point ``llm_primary`` at a port nothing listens on. The 
task
+succeeds, and its log shows the chain, the OpenAI SDK's two retries against 
the primary,
+and the model that finished the run:
+
+.. code-block:: text
+
+    Resolving LLM fallback chain: llm_primary -> llm_standby
+    Retrying request to /responses in 0.459459 seconds
+    Retrying request to /responses in 0.913650 seconds
+    ::group::Tool call: list_tables
+    Retrying request to /responses in 0.485229 seconds
+    Retrying request to /responses in 0.849597 seconds
+    ::group::Tool call: get_schema
+    ...
+    Durable: replayed 0 steps (0 model, 0 tool, 0 other), recorded 7 new steps 
(4 model, 3 tool, 0 other)
+    ::group::LLM run complete: model=claude-sonnet-5, requests=4, 
tool_calls=3, input_tokens=5135, output_tokens=334, total_tokens=5469
+    Tool call sequence: list_tables -> get_schema -> query
+
+Each of the run's four model requests tried the primary first and paid its 
retries
+before moving on. The chain keeps no memory of an outage between requests, so 
keep the
+primary's ``timeout`` short.
+
+Retries after the whole chain fails
+-----------------------------------
+
+With ``durable=True``, each step is recorded in the task instance's journal 
whichever
+connection answered it. When every connection fails, the task raises
+``FallbackExceptionGroup`` and Airflow retries it. The retry replays the 
recorded steps,
+then sends its first live request through the chain from the top. The run 
continues on
+the first connection that answers, whether or not that is the one that 
answered before.
+
+A recorded model step replays only if the retry's request has the same 
fingerprint, and
+for a chain the model in the fingerprint is the whole chain, whichever 
connection
+answered. Adding, removing or reordering a connection in 
``fallback_conn_ids``, or
+changing the ``model`` of a connection in it, between attempts therefore makes 
the retry
+run every step live, tools included. Changing a connection's credentials or 
host does
+not. "How a retry replays" in :doc:`durable_execution` gives the full matching 
rules.
+
+What is not covered
+-------------------
+
+- A tool that sent an email and then raised sends it again on retry, and so 
does one
+  whose worker died before its result was recorded. Make such tools 
idempotent; see
+  "Side effects and idempotency" in :doc:`durable_execution`.
+- A request every provider rejects, such as a malformed one that each returns 
a 400
+  for, walks the whole chain on every attempt. A ``RetryRule`` cannot stop 
those retries
+  without stopping outage retries too: once a chain is configured, both raise
+  ``FallbackExceptionGroup``, and a rule compares only that top-level type. 
Retry
+  policies need Airflow 3.3 or later; see "When the connection also carries a 
fallback
+  chain" in :doc:`retry_policies`.
+- No connection type builds the ``AnthropicVertex`` client that Claude on 
Vertex AI
+  needs, so a chain cannot include Claude on Vertex AI. A Vertex AI connection 
reaches
+  Gemini.
+- Failover and replay cover pydantic-ai agents only: ``fallback_conn_ids`` on 
the
+  pydantic-ai hooks and the operators built on them, and replay through 
``durable=True``
+  or the ``AirflowDurability`` capability in a plain ``@task``. A Strands
+  Agents agent run through ``invoke_durably`` resumes from its own checkpoint 
on
+  Airflow 3.3 and later (:ref:`howto/frameworks:strands-durable`), and a 
retried
+  ``LLMBatchOperator`` re-attaches to its running batch 
(:ref:`llm-batch-reattach`).
+- A ``ManagedAgentToolset`` runs again on every attempt unless it is built with
+  ``replayable=True``. Durable execution cannot be combined with 
``enable_hitl_review``,
+  a ``CodeMode`` capability, a ``SandboxToolset`` or tool approval; see the 
limitations
+  in :doc:`durable_execution`.
diff --git a/providers/common/ai/docs/retry_policies.rst 
b/providers/common/ai/docs/retry_policies.rst
index 5b19a1f2966..1ef857944f6 100644
--- a/providers/common/ai/docs/retry_policies.rst
+++ b/providers/common/ai/docs/retry_policies.rst
@@ -368,8 +368,9 @@ retry behaviour with no change to the Dag. Two things 
follow:
 
 * ``timeout`` stops bounding the whole classification call. pydantic-ai 
applies a
   ``ModelSettings`` timeout to each model in the chain, not to the chain as a 
whole, so a
-  30-second ``timeout`` across a three-connection chain is a 90-second worst 
case before the
-  policy falls back to ``fallback_rules``.
+  30-second ``timeout`` across a three-connection chain can take up to 90 
seconds before the
+  policy falls back to ``fallback_rules``, or 270 when the connections use the 
OpenAI or
+  Anthropic SDK, which retry a timed-out request twice.
 * If every connection in the chain fails, the classification call raises
   ``pydantic_ai.exceptions.FallbackExceptionGroup``. ``evaluate()`` still 
degrades to
   ``fallback_rules`` correctly -- it catches the broad ``Exception``, and an 
exception group is
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_recovery.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_recovery.py
new file mode 100644
index 00000000000..53b2d3210d1
--- /dev/null
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_recovery.py
@@ -0,0 +1,70 @@
+# 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.
+"""
+Example Dag: an agent task that keeps its progress through a provider outage.
+
+``fallback_conn_ids`` moves a failed model request to the next connection 
inside the
+attempt, and that connection continues the run from the same message history.
+``durable=True`` keeps what each attempt completed, so when every connection 
fails, or
+the worker running the task dies, the retry replays those steps and continues 
the run
+on whichever connection answers.
+
+Prerequisites:
+  - Connection ``llm_primary`` with ``conn_type='pydanticai'``, 
``password=<API key>``
+    and ``extra='{"model": "openai:gpt-5-mini"}'``
+  - Connection ``llm_standby`` with ``conn_type='pydanticai'``, 
``password=<API key>``
+    and ``extra='{"model": "anthropic:claude-sonnet-5"}'``
+  - Connection ``warehouse`` to a database with an ``orders`` table
+  - The provider installed with the ``anthropic``, ``openai`` and ``sql`` 
extras
+  - On Airflow before 3.3, ``[common.ai] durable_cache_path`` set to shared 
storage
+"""
+
+from __future__ import annotations
+
+from datetime import timedelta
+
+from airflow.providers.common.ai.operators.agent import AgentOperator
+from airflow.providers.common.compat.sdk import dag
+
+try:
+    from airflow.providers.common.ai.toolsets.sql import SQLToolset
+except Exception:
+    SQLToolset = None  # type: ignore[assignment,misc]
+
+
+# [START howto_agent_recovery]
+if SQLToolset is not None:
+
+    @dag(
+        default_args={"retries": 2, "retry_delay": timedelta(minutes=1)},
+        catchup=False,
+        tags=["example", "fallback", "durable"],
+    )
+    def example_agent_recovery():
+        AgentOperator(
+            task_id="investigate_orders",
+            prompt="Which region placed the most orders, and what share of all 
orders is that?",
+            llm_conn_id="llm_primary",
+            fallback_conn_ids=["llm_standby"],
+            durable=True,
+            system_prompt="You are a SQL analyst. Use the tools to answer with 
data.",
+            toolsets=[SQLToolset(db_conn_id="warehouse", 
allowed_tables=["orders"], max_rows=20)],
+        )
+
+    # [END howto_agent_recovery]
+
+    example_agent_recovery()

Reply via email to