The 2 AM Provider Outage
The pager goes off at 2 AM: the primary model provider is returning 503s. Your on-call checks the status page — a regional outage, ETA unknown. Every feature behind that provider is now a hard error page. Support tickets accumulate. By the time the provider recovers four hours later, you have an incident review, a few angry customers, and a new line in the postmortem: 'single point of failure.'
This is the scenario model fallback exists for. The premise is blunt: any provider you depend on will eventually fail, and the question is not whether your system survives it but what the user sees while it does.
No fallback:
Provider --503--> your service --> user sees error page (100% of traffic)
With fallback chain:
primary --503--> secondary --> user sees answer (maybe lower quality)
both down --> cached/template --> user sees degraded but useful response
The design goal is not perfection — it is graceful degradation. A shorter answer from a smaller model, a cached response, or even a well-written template reply beats an error page, because the user can still accomplish something. The rest of this topic builds the machinery: the fallback chain and health checks, the degradation ladder, and the chaos drills that prove the whole thing works before you need it.
Fallback Chain Routing with Health Checks
A fallback chain is an ordered list of providers plus the logic that walks it. The logic has two jobs: pick a healthy provider, and react when the one you picked fails.
request --> pick_provider()
|
v
┌──────────┐ healthy ┌────────┐
│ primary │──────────>│ answer │
└──────────┘ └────────┘
│ circuit open (5 failures / 60s)
v
┌──────────┐ healthy ┌────────┐
│secondary │──────────>│ answer │
└──────────┘ └────────┘
│ also open
v
┌──────────┐ ┌────────┐
│ degraded │──────────>│cached /│
│ response │ │template│
└──────────┘ └────────┘
Health checks come in two flavors. Passive checks infer health from real traffic: consecutive failures and latency percentiles. Active checks probe the provider on a schedule with a lightweight request. Passive checks are free and reflect real user experience; active checks catch a provider that is up but broken for your specific request shape. Production systems use both — passive for the circuit state, active for early warning.
import time
PROVIDERS = ['primary', 'secondary']
FAILURES_TO_OPEN = 5
CIRCUIT_OPEN_FOR = 60 # seconds
failure_counts = {p: 0 for p in PROVIDERS}
last_failure = {p: 0.0 for p in PROVIDERS}
def pick_provider():
for name in PROVIDERS:
# a provider re-enters rotation once its cooldown expires — the
# same half-open idea as a circuit breaker, applied per provider
if time.monotonic() - last_failure[name] > CIRCUIT_OPEN_FOR:
return name
return None # every provider is down — degrade, do not error
def complete(request):
provider = pick_provider()
if provider is None:
return degraded_response(request)
try:
answer = call(provider, request)
failure_counts[provider] = 0 # any success heals the circuit
return answer
except ProviderError:
failure_counts[provider] += 1
last_failure[provider] = time.monotonic()
if failure_counts[provider] == FAILURES_TO_OPEN:
alert(f'{provider} circuit open — traffic shifting to next tier')
return complete(request) # walk to the next provider in the chain
The recursion is the whole design: try the chain in order, heal on any success, and only reach the degraded response when every provider has refused. Note that a success resets the failure count — a provider that fails twice then succeeds once is healthy, and the circuit should say so.
The Degradation Ladder
Fallback is not binary. Between 'full answer from the primary model' and 'error page' there is a ladder of degraded modes, and choosing the right rung is a product decision as much as an engineering one.
| Tier | Trigger | User sees | Quality |
|---|---|---|---|
| Primary model | normal operation | full answer | best |
| Secondary model | primary circuit open | full answer, possibly lower quality | good |
| Cached answers | both providers down | last known good answer | stale but relevant |
| Template reply | cache miss | 'I'm at capacity — here is the documentation link' | honest |
| Error page | nothing else works | clear failure with retry guidance | none |
The ordering principle: degrade the answer before you degrade the availability. A cached answer from yesterday is more useful than an error today, because the user can act on it. A template reply that says 'I cannot answer right now, but here is where to look' preserves trust; an error page destroys it.
Quality differences across the ladder are the reason for alerting. When traffic shifts to the secondary tier, the answers are measurably different — shorter, less nuanced, sometimes wrong in ways the primary never was. If nobody is watching, the backup tier can become the default tier for weeks, and your quality metrics silently erode. Alert on the percentage of traffic served by each tier: anything above a few percent on the secondary is a signal, not a noise floor.
The cached-answers rung deserves a caveat from response caching: only serve cached answers when the cache was warm before the outage. A cold cache during an outage means every request misses, and the 'degraded' tier is slower than the error page it replaced.
Chaos Drills and Backup-Tier Alerting
Your fallback chain is configured, the code is deployed, and nobody has ever seen it work. That is the default state of failover logic — and the reason it so often fails when finally needed. Untested failover is not failover; it is a hypothesis.
Chaos drills turn the hypothesis into evidence. The practice is simple: in staging, kill the primary provider and assert that the feature still answers.
def chaos_drill():
with provider_down('primary'): # staging-only fault injection
start = time.monotonic()
answer = complete(sample_request)
elapsed = time.monotonic() - start
assert elapsed < 3.0, f'fallback too slow: {elapsed:.2f}s'
assert answer.source in ('secondary', 'cache', 'template'), \
f'fallback did not engage: {answer.source}'
Run the drill on every deploy that touches the request path, and after any provider configuration change. The assertions matter as much as the kill: 'still answers' is vague, 'answers within 3 seconds from a non-primary source' is a contract.
The drill usually finds the same three bugs:
- The chain is not actually ordered. A config file lists providers in the wrong order, or an environment variable overrides the list in staging only.
- The degraded response is broken. The template reply references a URL that 404s, or the cache lookup throws instead of missing.
- Healing does not work. The circuit never closes, so once the primary is down it never returns even after recovery — the cooldown logic from the previous chapter is the fix.
Drills also answer the question your incident review will ask: how long does failover take? Measure it in the drill — time from primary failure to first successful secondary answer — and put the number in your runbook. When the real outage comes, 'we drill this monthly and failover takes 400 milliseconds' is the answer that ends the incident review.
Fallback is the last layer of the resilience stack this phase builds: rate limiting and retries keep you from overwhelming providers, and fallback keeps you serving when they are gone. The capstone for this phase, the production RAG assistant, expects all of it.
Quiz
1. What is the primary purpose of a fallback chain?
2. Why should a success reset the failure count in a fallback circuit?
3. Why alert when a large share of traffic shifts to the secondary tier?
4. Why run chaos drills on fallback logic?
Flashcards
Question
What is model fallback?
Click to reveal answer
Answer
An ordered chain of providers plus routing logic: when the primary fails, traffic walks to the secondary; when all providers fail, the user gets a degraded response instead of an error page.
Question
Why does a degraded response beat an error page?
Click to reveal answer
Answer
It preserves availability and trust: a cached answer or template reply lets the user accomplish something, while an error page ends the interaction. Degrade the answer before degrading availability.
Question
What do passive and active health checks do?
Click to reveal answer
Answer
Passive checks infer health from real traffic (consecutive failures, latency); active checks probe the provider on a schedule. Passive reflects user experience; active catches providers that are up but broken for your request shape.
Question
Why reset the failure count on success?
Click to reveal answer
Answer
Consecutive failures are the signal. A provider that fails twice then succeeds is healthy — resetting heals the circuit automatically so a recovered provider returns to rotation without manual intervention.
Question
What does a chaos drill assert about fallback?
Click to reveal answer
Answer
That with the primary killed, the feature still answers within a latency budget from a non-primary source (secondary, cache, or template) — turning untested failover into a measured contract.
Revision Notes
Key Takeaways
- 1. A fallback chain keeps features answering when the primary provider fails: walk an ordered list, heal on any success, degrade only when every provider refuses
- 2. Degrade the answer before the availability — a cached answer or template reply beats an error page because the user can still act
- 3. Alert on tier distribution: a silent shift to the backup tier means quality is eroding unnoticed
- 4. Untested failover is a hypothesis; chaos drills with latency and source assertions turn it into evidence
Interview Tips
- • Open with the 2 AM outage story — single point of failure, error pages, angry customers — then present the chain as the fix with the degradation ladder
- • Expect 'how do you know the fallback works?' — the chaos drill answer (kill primary, assert latency and source) is what separates design from hope
- • Mention quality explicitly: fallback models answer differently, so tier-traffic alerting is how you avoid silently shipping worse answers for weeks
Cheat Sheet
Model Fallback and Degradation Cheat Sheet
Fallback chain:
def pick_provider():
for name in PROVIDERS:
if time.monotonic() - last_failure[name] > CIRCUIT_OPEN_FOR:
return name
return None # all down -> degrade
def complete(request):
provider = pick_provider()
if provider is None:
return degraded_response(request)
try:
answer = call(provider, request)
failure_counts[provider] = 0 # success heals
return answer
except ProviderError:
failure_counts[provider] += 1
last_failure[provider] = time.monotonic()
return complete(request) # walk the chain
Degradation ladder: primary -> secondary -> cached answer -> template reply -> error page. Degrade the answer before the availability.
Alert on: % of traffic per tier — a few percent on secondary is a signal, not noise.
Chaos drill: kill primary in staging -> assert answer < 3s from non-primary source. Run on every deploy touching the request path.
Common drill findings: chain not actually ordered · degraded response itself broken · circuit never heals (no cooldown reset)
Continue Learning
Before You Start
- No prerequisites listed.