Fallback Chains: Degrading Gracefully When a Model or Tool Fails
A fallback chain is an ordered list of alternatives the host tries when a primary model or tool fails.
Search across all documentation pages
A fallback chain is an ordered list of alternatives the host tries when a primary model or tool fails.
The goal is controlled degradation: slower, cheaper, narrower, or human-backed paths instead of a hard crash or a confident lie.
Define explicit primary and fallback steps, classify which errors may advance the chain, preserve user-visible honesty about reduced capability, budget total attempts, and log which step actually served the request.
served_by and degraded=true when not primary.from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class StepResult:
ok: bool
data: Any = None
step: str = ""
error_type: str | None = None
advance: bool = False # may try next fallback
message: str | None = None
@dataclass
class ChainResult:
ok: bool
data: Any = None
served_by: str | None = None
degraded: bool = False
attempts: list[str] | None = None
message: str | None = None
def run_fallback_chain(
steps: list[tuple[str, Callable[[], StepResult]]],
*,
primary_name: str,
) -> ChainResult:
attempts: list[str] = []
last_message = "no steps"
for name, fn in steps:
attempts.append(name)
result = fn()
if result.ok:
return ChainResult(
ok=True,
data=result.data,
served_by=name,
degraded=(name != primary_name),
attempts=attempts,
)
last_message = result.message or result.error_type or "error"
if not result.advance:
break
return ChainResult(
ok=False,
served_by=None,
degraded=True,
attempts=attempts,
message=last_message,
)
# --- dependency sketches ---
def primary_search(q: str) -> StepResult:
raise_demo = True # flip to False to see happy path
if raise_demo:
return StepResult(ok=False, step="search_api", error_type="upstream", advance=True, message="503")
return StepResult(ok=True, step="search_api", data=[f"hit:{q}"])
def cached_search(q: str) -> StepResult:
cache = {"refund": ["cached refund policy"]}
if q in cache:
return StepResult(ok=True, step="cache", data=cache[q])
return StepResult(ok=False, step="cache", error_type="not_found", advance=True, message="cache miss")
def keyword_search(q: str) -> StepResult:
return StepResult(ok=True, step="keyword", data=[f"keyword-match:{q}"])
def search_with_fallbacks(q: str) -> ChainResult:
steps = [
("search_api", lambda: primary_search(q)),
("cache", lambda: cached_search(q)),
("keyword", lambda: keyword_search(q)),
]
return run_fallback_chain(steps, primary_name="search_api")
print(search_with_fallbacks("refund"))Prompts like "if search fails, just answer from memory" create silent quality cliffs and hallucinations.
Host-defined chains:
| Layer | Example chain | Notes |
|---|---|---|
| Model | frontier → mid → small / other provider | Keep tool schemas portable; log model id |
| Tool / data | live API → cache → keyword → human queue | Prefer read-only degrades before inventing facts |
| Product UX | full agent → single-shot answer → "try later" | Be honest about limits |
OpenRouter-style routing can implement model chains; you still need tool and product chains around it.
Advance on:
circuit_openDo not advance on:
invalid_args (fix args on the same step)If you served cache or a smaller model:
Sum of step timeouts can exceed the user SLO.
Allocate a chain deadline and skip remaining optional degrades when time is nearly gone. Prefer one good fallback over four late ones.
If the primary breaker is open, skip straight to the next step without spending probe budget on every user request. Dedicated half-open probes can stay on a background or sampled path.
Specialists should not each invent their own secret fallbacks to admin tools.
Define chains at the capability layer so handoffs cannot escalate privilege as a "fallback."
| Approach | Strength | Weakness |
|---|---|---|
| Ordered host chain (this recipe) | Explicit, testable | Requires design up front |
| Single hard fail | Simple mental model | Poor UX under partial outages |
| Model-chosen recovery only | Flexible | Unreliable under stress; hard to audit |
| Active-active dual primary | Higher availability | Costly; consistency issues |
| Queue for later processing | Good for async work | Bad for interactive chat |
Usually 2-3 steps. Beyond that, prefer a clear failure or human queue.
Every user-critical capability should. Internal enrichment tools can fail closed if the run can continue without them.
Only if evals show acceptable quality for that task class. Otherwise you degrade into confident nonsense.
Degrade paths that still perform R2-R3 actions must keep the same gates. Failure is not permission to skip HITL.
A structured failure with stop_reason, partial results if safe, and a user-safe message. Do not free-compose a fake completion.
Yes for static knowledge. For live account state, cache is usually a degrade with freshness labels.
Inject failures at each step; assert order, advance rules, degrade flags, and that unsafe steps never appear.
Some routers and agent frameworks support model fallback lists (verify at build). Tool/product chains are still your design.
Share a chain budget and smaller per-step budgets so one dead primary cannot exhaust everything.
Track availability of the capability (any successful step) and a separate quality SLO for primary serve rate.
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.
Reviewed by Chris St. John·Last updated Jul 16, 2026