Designing a model fallback chain: resilience without complexity
A production LLM feature that depends on a single provider has a reliability ceiling at whatever uptime that provider delivers. Provider outages, rate limits, and transient errors are real. A well-designed fallback chain decouples your reliability from any single provider's availability.
The tricky part is building fallback without creating a mess of nested try/except blocks and special cases. This article shows the clean approach.
The fallback chain model
A fallback chain is an ordered list of (provider, model) pairs. When a call fails, move to the next entry. Return the first success. If all entries fail, raise the last error.
The chain should degrade in capability and often in cost:
Primary: claude-sonnet-4-5 (best quality, ~$3/$15 per M tokens)
Fallback 1: claude-haiku-4-5 (good quality, ~$0.80/$4 per M tokens)
Fallback 2: gpt-4o-mini (good quality, ~$0.15/$0.60 per M tokens)
For most tasks, Haiku and GPT-4o mini produce acceptable output. The primary model delivers the best experience; fallbacks deliver acceptable experience during primary outages.
What triggers a fallback vs. a retry
Not all errors justify fallback. The distinction:
Retry on the same model:
- 429 Rate Limit — your request rate exceeded the limit; backoff and retry
- 500, 503 Server Error — transient provider-side failure
- Timeout — transient network or load issue
Fall back to next model:
- 429 after 3 retries — you have exhausted retries; escalate to fallback
- Persistent 5xx — provider infrastructure degraded; try alternative
- Complete provider outage (connection refused, DNS failure)
Do not retry OR fall back:
- 400 Bad Request — your payload is wrong; fix it
- 401 Unauthorized — your API key is invalid or expired; alert immediately
- 422 Unprocessable Entity — your request schema is invalid; fix it
The clean implementation
The pattern is a chain of providers wrapped in a FallbackProvider that implements the same interface as a single provider:
class FallbackProvider(ProviderClient):
"""Tries providers in order, returns first success."""
def __init__(
self,
chain: list[tuple[ProviderClient, str]], # (client, model_name)
max_retries_per_provider: int = 2,
) -> None:
self._chain = chain
self._max_retries = max_retries_per_provider
async def generate(self, system: str, messages: list[dict], **kwargs) -> LLMResult:
last_exc = None
for client, model in self._chain:
for attempt in range(self._max_retries + 1):
try:
result = await client.generate(
system=system,
messages=messages,
model=model,
**kwargs,
)
if attempt > 0 or client is not self._chain[0][0]:
logger.warning(
"fallback_used",
extra={"model": model, "attempt": attempt},
)
return result
except ProviderError as exc:
last_exc = exc
if not exc.retryable:
break # don't retry non-retryable errors
if attempt < self._max_retries:
await asyncio.sleep(1.5 ** attempt)
except Exception as exc:
last_exc = exc
break # unexpected errors: try next provider
raise RuntimeError(f"All providers failed. Last error: {last_exc}")
Application code creates the chain once at startup and never changes:
providers = FallbackProvider(chain=[
(anthropic_client, "claude-sonnet-4-5"),
(anthropic_client, "claude-haiku-4-5"),
(openai_client, "gpt-4o-mini"),
])
Observability for fallback chains
When fallbacks are triggered, you need to know:
- Which model was actually used for each request
- How often fallbacks are triggered
- Which errors triggered them
Include model_used and fallback_triggered in every request trace:
@dataclass
class RequestTrace:
request_id: str
primary_model: str
model_used: str
fallback_triggered: bool
fallback_reason: str | None
retries: int
latency_ms: int
cost_usd: float
A spike in fallback_triggered is an early warning of provider instability before it becomes a full outage.
Cost implications of fallback
Fallbacks during retries mean some requests cost more than the primary model price. For 99% of requests using the primary, cost is predictable. For the 1% that fall back, costs may vary:
- Falling back to Haiku from Sonnet: ~4x cheaper
- Falling back to GPT-4o mini from Sonnet: ~20x cheaper
This means fallbacks actually reduce cost during outages — a nice side effect. Model on fallbacks by building cost tracking into the chain.
When NOT to use a fallback chain
Fallback chains add latency overhead on the failure path (retry delays + trying multiple providers). In features where latency is the top priority, a single fast model with aggressive timeout and graceful degradation may serve better than a chain.
Also: do not fall back when the request itself is the problem. A 400 error means your payload is wrong. Trying the same payload on a different model will produce the same error. Fix the payload, not the provider selection.
Summary
A well-designed fallback chain improves reliability with minimal code complexity. The key rules: implement one interface, retry on transient errors, fall back on provider failures, log which model was used, and distinguish retriable from non-retriable errors. Build the chain at startup, inject it as a dependency, and your application code never needs to know which model actually answered the request.