方寸 Portal

AI Engineer Portal

Your personal operating system for career transition.

Private mode

Knowledge Note

Designing a model fallback chain: resilience without complexity

How to build provider fallback chains that improve reliability, reduce cost on retry, and degrade gracefully — without making your codebase a tangle of exception handling.

Category

architecture

Tags

llm-apps · resilience · fallback · providers · reliability · architecture

Sources

2 linked references

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.