LLM Fallbacks in Production: Routing, Retries, and Timeouts
9 min read · Last verified August 8, 2026
An LLM fallback is the model that serves the request when your first choice can't, and the production version needs three things the naive one skips: a routing rule that decides when to switch, an eval that proves the backup is good enough, and a log line recording which model actually answered.
You already know how to wrap a flaky upstream: timeouts, retries with backoff, circuit breaker, fallback. LLM APIs need exactly that playbook with one amendment that changes several defaults: every retry has a price tag. A retried Postgres query costs microseconds of CPU; a retried frontier-model call costs real money and 10+ seconds. Naive retry loops here aren't a reliability bug; they're a money bug that a provider incident can turn into a four-figure invoice overnight.
Here's the playbook, error by error, ending at the fallback chain and the router in front of it. This is what it looks like on the wire when everything that can go wrong does:
The error taxonomy: what to do per status
The two big providers document their error shapes (Anthropic, OpenAI); operationally they collapse into one table:
| Status | Meaning | Retry? | What to actually do |
|---|---|---|---|
| 400 | Malformed request / prompt too long | Never | Fix the request. If context overflow: shrink input, don't resend |
| 401 / 403 | Bad or revoked key | Never | Page. This is config, not weather |
| 404 | Model id doesn't exist / deprecated | Never | Config fix; alarm (a deprecation just found you) |
| 413 | Request too large | Never | Shrink; same request = same failure at same cost |
| 429 | Rate limit / quota | Yes, with discipline | Honor retry-after, jittered backoff, shed load upstream |
| 500 | Provider internal error | Yes | 1 sync retry / 2–3 async, jittered backoff |
| 529 / 503 | Overloaded | Yes, gently | Backoff harder; this is the fallback-chain trigger |
| Timeout | You gave up | Depends | TTFT timeout → treat as 5xx. Total-duration timeout → probably don't retry; ask for less |
200 + stop_reason: max_tokens | Truncated success | No | Not an error: handle the partial or re-ask with a tighter prompt |
| 200 + refusal / content filter | Model declined | No | Don't blind-retry; branch product logic |
The bottom two rows are the LLM-specific trap: failures that arrive as 200s. Your HTTP-level retry wrapper will never see them; response-level checks (stop_reason, covered in the API lesson) must. Retrying is the wrong tool for both rows anyway: deciding what your API serves instead is failure design, covered in its own lesson. A third non-retryable family joins the table once outputs are schema-validated: a validation failure gets one repair re-ask with the validator's error in hand, never a transport retry.
Retries: the standard pattern, with a budget
The mechanics are the ones AWS documented years ago (exponential backoff with full jitter), plus two LLM-specific rules:
const RETRYABLE = new Set([429, 500, 529, 503]);
async function callWithRetry(req: LLMRequest, maxRetries: number): Promise<LLMResponse> {
for (let attempt = 0; ; attempt++) {
try {
return await callLLM(req, { timeoutMs: req.ttftBudgetMs });
} catch (err) {
if (!RETRYABLE.has(err.status) || attempt >= maxRetries) throw err;
const retryAfterMs = err.retryAfterMs // provider said when; believe it
?? Math.random() * Math.min(30_000, 1000 * 2 ** attempt); // else: full jitter, capped
await sleep(retryAfterMs);
}
}
}
// Sync path (user waiting): callWithRetry(req, 1)
// Async worker: callWithRetry(req, 3); then the queue's own retry takes overRule 1: retries spend real money. Set maxRetries by path: one retry when a user is waiting (past that, your latency budget is gone anyway; degrade instead), two or three in queue workers where the queue's dead-letter machinery is the final backstop. Meter retry spend: a retries × cost metric per feature catches the worker that's quietly re-buying the same generation all night.
Rule 2: retry the call, not the side effects. LLM calls aren't idempotent in output (same input, different words; that's the sampling lesson) but they're side-effect-free, so re-calling is safe. The danger is downstream: if the model's output triggers an email, a refund, a tool execution, idempotency-key those operations. Generation is retryable; actions are not.
A 429 deserves one more note: it means back off, not try harder. Under a provider incident, every client's naive retry loop synchronizes into a thundering herd: the cascading-failure pattern with a billing meter attached. retry-after is the provider telling you the herd schedule; honor it. Better still, throttle yourself below the published limits so most 429s never fire at all.
Timeouts: two stages, not one
A flat timeout is wrong twice for LLM calls, because latency has two phases: time-to-first-token (queueing + prompt processing, usually sub-second, occasionally stuck) and generation (legitimately tens of seconds for long outputs).
- TTFT timeout, tight (~10s): nothing arrived → the call is probably wedged. Abort, retry once, or fall back. This is your real health signal.
- Total-duration timeout, generous (60–120s): protects against runaway streams. If you hit it regularly, the fix is a smaller
max_tokensor a terser prompt, not a bigger timeout. - Inter-token stall timeout (streaming, optional): no delta for ~15s mid-stream → the stream died; end it cleanly rather than leaving the user watching a frozen cursor.
Set these from your caller's budget, not the provider SDK defaults, which are effectively "forever."
Fallbacks: the chain, and its two flavors
When retries exhaust, the next move is a different model. Two flavors, different purposes:
Cross-provider, same tier: for availability. Provider A is down; a comparable model from provider B serves. Costs you a second integration and prompt portability (keep prompts provider-neutral or maintain two variants; the two-dialect reality makes this cheaper than it sounds).
Same provider, smaller model: for overload and cost pressure. Opus-class is 529ing or your spend cap tripped; Sonnet- or Haiku-class serves a good-enough answer. This is model routing wearing its incident-response hat.
const CHAIN = [
{ provider: "anthropic", model: "claude-sonnet-5" },
{ provider: "openai", model: "gpt-5" }, // availability fallback
{ provider: "anthropic", model: "claude-haiku-4-5" }, // degraded-quality fallback
];Three rules that keep the chain honest. Eval the fallback before you need it: a fallback that's never been run against your eval set isn't a fallback, it's a hope. Log which link served every request: quality complaints during an incident are unexplainable without it. Put the chain in one place: the gateway chokepoint, not copy-pasted per call site.
And below the whole chain sits the real floor: degrade the feature. Suggestion returns null, summary shows "unavailable," the page still renders. An LLM feature that can't degrade isn't a feature, it's a dependency.
Fallback routing: pick the model before the failure, not after
A chain is reactive and fixed: try A, then B, then C, in that order, every time. Fallback routing puts a decision in front of the chain, per request, using signals you are already collecting. The chain answers "what next"; the router answers "what first, and why."
Four signals are worth routing on, and the order matters:
| Signal | Where it comes from | What it decides |
|---|---|---|
| Capability | Static config per model | Hard gate. Can this model call your tools, fit the context, honor your schema? |
| Health | Your circuit breaker (below) | Skip providers that are currently failing, before you pay to discover it |
| Budget | Per-feature spend counter | Spend cap near its limit? Downshift a tier instead of erroring |
| Request class | Your own routing logic | Cheap requests to cheap models; escalate only the hard minority |
// Capability is a gate, not a preference: falling back to a model that can't
// fit the context isn't a fallback, it's a second failure with extra latency.
function route(req: LLMRequest, health: HealthByProvider): ModelRef[] {
const capable = CHAIN.filter((m) => supports(m, req));
const healthy = capable.filter((m) => !health[m.provider].circuitOpen);
const pool = healthy.length > 0 ? healthy : capable; // all sick: try anyway, degraded
return req.budgetRemainingUsd < req.estimatedCostUsd
? pool.filter(isCheapTier) // downshift, don't fail
: pool;
}Two failure modes specific to routing. Sticky within a conversation: if turn 4 gets answered by a smaller model than turns 1-3, the voice and format shift mid-thread and users read it as a bug. Pin the chosen model for the life of a session unless it goes unhealthy. Never route on latency alone: the fastest model is usually the weakest, so a p95 spike will quietly migrate all your traffic to your worst model and the graphs will look great.
Circuit breaker: stop paying to fail
Retries and fallbacks handle one request; a circuit breaker handles the incident. Track error rate per provider; past a threshold (say, 50% over 30s), open the circuit (route straight to fallback or degrade, skip the doomed-and-billed attempts) and let periodic probes close it when the provider recovers. Any standard breaker library works unchanged; the LLM twist is that an open circuit saves money, not just latency, which makes the business case for wiring it unusually easy.
If you remember one thing: classify before you retry (most 4xx-class failures can only be fixed, not retried, and some failures arrive as 200s), spend retries like the money they are, and make the last link in every chain a graceful degrade. The track builds the gateway that gives all of this one home.
Sources & further reading: Anthropic API errors · OpenAI error codes · Exponential backoff and jitter (AWS) · Google SRE: cascading failures
FAQ
What is LLM fallback routing?
A fallback chain is reactive and ordered: try model A, and on failure try model B. Fallback routing decides which model goes first, per request, before anything fails, using signals you already track: circuit-breaker state per provider, remaining spend budget, and whether the model can actually do the job (tool calling, context length, structured output). The chain answers what next; the router answers what first, and why.
How do I automate LLM fallbacks without silently degrading quality?
Three things make automation safe. Gate on capability before health, so you never fail over to a model that cannot fit the context or call your tools. Run your eval suite against every link in the chain, because an unevaluated fallback is a hope, not a fallback. And log which model actually served each request, so a spike in complaints during an incident is explainable instead of mysterious.
How many times should I retry a failed LLM API call?
Once on a synchronous request path (a user is waiting; your latency budget is already blown), two to three times with jittered exponential backoff in async workers. Retry only retryable errors (429s and 5xx/overloaded) and respect the retry-after header. Never retry 4xx validation errors; the same request will fail the same way, at the same price.
Are LLM API calls idempotent (is it safe to retry them)?
The call itself is side-effect-free: retrying costs money but corrupts nothing, and you'll get a different-but-equivalent response. What's dangerous is retrying the side effects around the call: sending the email the model drafted, executing the tool action. Make the consumer idempotent, not the generation.
Should my fallback be a different provider or a smaller model?
For availability incidents, a same-tier model from a different provider: capability parity, at the cost of a second integration and prompt portability. For overload or cost pressure, a smaller same-provider model degrades gracefully. Either way, eval the fallback path before you need it and log which path served every request.
What timeout should I set when some responses legitimately take 30 seconds?
Two timeouts, not one: a tight time-to-first-token timeout (about 10 seconds; if nothing has arrived by then, the call is likely stuck) and a generous total timeout (60–120 seconds) that protects against infinite streams. A single flat value is wrong at both ends.