Topic 13.3
Model Fallbacks and Degraded Modes
In one line
Providers have bad hours. A second model can answer when the first one fails in a way another model could fix, the screen must say which mode it is in, and a kill switch lets you turn the feature off in seconds with a useful page left behind.
Think of it like this
A restaurant when the main oven breaks. The kitchen can switch to the small oven and serve a shorter menu, and a sign at the door says so. If both ovens are down, the owner puts a note on the door with the phone number of the shop next street. Nobody is left standing outside without knowing what is happening.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Fallback
- A second model (or a simpler path) used when the first fails.
- Middleware
- A function that wraps another one to add behaviour. In the AI SDK it wraps a model's calls.
- Retryable error
- A failure that may succeed if tried again or elsewhere: 429, 500, 502, 503.
isRetryableonAPICallErrorsays so. - Circuit breaker
- A guard that stops calling a failing service for a while, then tries one request to see if it recovered.
- Kill switch
- A flag that turns a feature off immediately, without a deploy.
- Feature flag
- A setting read at run time that turns a feature (or part of it) on or off for everyone or for some users.
- Degraded mode
- A reduced but still useful state: shorter answers, fewer features, or a plain help page.
- 503 Service Unavailable
- The status for "not available right now". Your own kill-switch response uses it.
Step by step
01Wrap the model on the server
withFallback(first, second, onFallback) returns a model made by wrapLanguageModel. Its wrapStream tries doStream(). When that throws a retryable error it records that it fell back and returns second.doStream(params).
The route creates this wrapper per request, so the variable that remembers "we used the backup" belongs to one request only.
import { wrapLanguageModel } from "ai";
function withFallback(first, second, onFallback) {
return wrapLanguageModel({
model: first,
middleware: {
wrapStream: async ({ doStream, params }) => {
try {
return await doStream();
} catch (e) {
if (!e.isRetryable) throw e; // our own mistake: do not hide it
onFallback(e.statusCode);
return second.doStream(params);
}
},
},
});
}
// in a real app: first = your main provider model, second = a cheaper or different one02Tell the page which model answered
The first live example scripts four questions. The main model fails twice with 503, then works, then fails with 401. Questions 1 and 2 are answered by the backup and carry the "Backup mode" label. Question 3 is answered normally. Question 4 shows the isRetryable check at work: a 401 is not hidden by the backup, the server logs not recoverable: 401 and the page shows "Mitra could not answer right now."
The server's onError returns a short message for the page. Never return the provider's raw error text, which can contain internal details.
03Three screens from one error
The second example turns the same failure signal into three states. All fine: a normal answer. Both models down: Mitra is having trouble with three help links, the Ask button still usable so the user can retry. Kill switch on: Mitra is switched off for a short while, the button disabled, nothing sent (the fourth line).
The flag is read on every request, so flipping flags.aiEnabled takes effect on the next question with no deploy.
04Do not hammer a dead provider
The third example is a circuit breaker. The main model fails on its first three calls. After two failures in a row the breaker opens and the next three requests go straight to the backup: the console shows the main model call count staying at 2. Request 6 is the trial: it fails (call 3), so the breaker opens again. Request 10 is the next trial: it works, the breaker closes, and requests 10 and 11 use the main model again.
Try it yourself
- 1
Take the backup down too
In the first example change
backuptofakeModel({ fail: { status: 503 } }, quick). Question 1 now fails: the server logs the 503 from the backup too, and the page shows "Mitra could not answer right now." and no answer. Both models down is a real state. The second example below shows the screen to design for it. - 2
Open the breaker sooner
In the third example change
breaker.failures >= 2to>= 1. Only 3 of the 11 requests reach the main model (requests 1, 5 and 9), and all three fail, so the main model never gets the fourth call that would have succeeded. A sensitive breaker protects the provider and the user's time, and a very sensitive one stays away from a model that had one bad moment. - 3
Flip the switch twice
In the second example delete the line
providersUp = true;beforeflags.aiEnabled = false;. The providers are still down when the switch goes on, and step 3 prints the same "switched off" panel as before: the flag is read first and no model is called. Notice no code is deployed: the next request just reads the new value.
Code & diagrams
The two models are scripted stand-ins. In a real app, `first` and `second` are two provider models (for example a larger and a smaller one). The one line that changes is where `main` and `backup` are created.
Preview
Console
Nothing logged yet.Expected console output
(server) main model failed with 503 → using the backup
question 1 → 1 answers on screen, newest: Shipping takes 1 to 2 days. (Backup mode: shorter answers while the main model recovers) | error: none
(server) main model failed with 503 → using the backup
question 2 → 2 answers on screen, newest: Shipping takes 1 to 2 days. (Backup mode: shorter answers while the main model recovers) | error: none
question 3 → 3 answers on screen, newest: Orders ship in one to two working days, and you can track them in your account. | error: none
(server) not recoverable: 401
question 4 → 3 answers on screen, newest: Orders ship in one to two working days, and you can track them in your account. | error: Mitra could not answer right now.The 503 from the kill switch arrives as a normal response before any stream starts, so the page sees an error whose message is the JSON body. The `ai_paused` code is made up for this example.
Preview
Console
Nothing logged yet.Expected console output
1 all fine → answers: 1 | panel: none | button disabled: false
2 both models down → answers: 1 | panel: Mitra is having trouble. You can try again, or use these pages. | button disabled: false
3 kill switch on → answers: 1 | panel: Mitra is switched off for a short while. | button disabled: true
4 click while paused → button is disabled, nothing sentRequests 3 to 5 and 7 to 9 never touch the main model: no wasted wait, no extra load on a provider that is struggling. Request 6 and 10 are the single trial requests.
Preview
Console
Nothing logged yet.Expected console output
request 1 → Backup answer. [main failed with 503] | main model calls so far: 1
request 2 → Backup answer. [main failed with 503, breaker opened] | main model calls so far: 2
request 3 → Backup answer. [breaker open, main model not called] | main model calls so far: 2
request 4 → Backup answer. [breaker open, main model not called] | main model calls so far: 2
request 5 → Backup answer. [breaker open, main model not called] | main model calls so far: 2
request 6 → Backup answer. [main failed with 503, breaker opened] | main model calls so far: 3
request 7 → Backup answer. [breaker open, main model not called] | main model calls so far: 3
request 8 → Backup answer. [breaker open, main model not called] | main model calls so far: 3
request 9 → Backup answer. [breaker open, main model not called] | main model calls so far: 3
request 10 → Main model answer. [main] | main model calls so far: 4
request 11 → Main model answer. [main] | main model calls so far: 5Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Fall back on every error
In the first example delete the line if (!e.isRetryable) throw e; ... inside wrapStream.
Break #2
No breaker
In the third example change breaker.skip = 3; to breaker.skip = 0;.
Myth vs fact
Myth
A fallback model gives the same answers.
Fact
A second model is a different model. Its answers can be shorter, different in style and sometimes worse. That is why the page labels the mode.
Myth
Retrying the same call is always enough.
Fact
The SDK already retries some failures (set maxRetries). A provider-wide outage needs a different model or a degraded page, not more retries of the same call.
Myth
A kill switch is for emergencies only.
Fact
It is also a rehearsal tool: turn it on in a quiet hour to check that the degraded page really works. A kill switch never tested is a guess.
Myth
Show the raw error so users can report it.
Fact
Raw provider errors can include internal details and mean nothing to a shopper. Show a human sentence, log the details on the server.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Fallback changes cost and privacy. A backup from a *different* provider means your users' text goes to another company: check your agreements and your privacy page before enabling it.
- ▸
Keep the fallback chain short (main, backup, help page). Long chains hide problems and multiply waiting time, since each failed attempt takes its own time to fail. A *slow* provider is a failure too: the SDK's
timeoutoption (for example{ firstChunkMs: 2000 }) ends a call that is too slow, but it aborts instead of falling back, so a race insidewrapStreamis needed to switch to the backup (see the practice answer). - ▸
Feature flags can be narrower than on/off: switch off only tools, only long inputs, or only a percentage of users while you investigate. Always have one flag that turns the whole feature off.
- ▸
Record every fallback and every breaker open in your logs as an event with a status code and no user text (Topic 13.5). A rising fallback rate is the earliest sign of trouble.
Remember this
- 1
The problem this solves: Your AI feature depends on a service you do not control. It will be slow, overloaded or down at some point, and sometimes it is *you* who must stop it (a bug that is costing money, a safety problem). Without a plan the user sees a spinner forever or a raw error. This topic builds the plan: a fallback model, honest labels, a circuit breaker and a kill switch.
- 2
A fallback is a second model used when the first one fails. Put it on the server, around the model call, so the page does not need to know. In the AI SDK you wrap a model with
wrapLanguageModeland a middleware: a small object whosewrapStreamfunction receivesdoStream(the real call) and the call'sparams. TrydoStream(); if it throws, call the second model'sdoStream(params)and return that. The page receives an ordinary stream either way. - 3
Fall back only for failures a second model can fix. Every
APICallErrorhasstatusCodeandisRetryable. A429(busy) and a5xx(provider trouble) are retryable: another model may work. A401(bad key) or400(bad request) is *your* mistake: a backup would hide it, and you would only notice when the backup also fails or when the bill shows nobody uses your main model. Re-throw those so they show up in your alerts (first live example, question 4). - 4
What this wrapper cannot catch: a stream that breaks after it started. The
tryonly covers the momentdoStream()is called, which is when a 429 or 503 arrives as a normal HTTP answer. If the connection drops after the model has already sent three words, the user has already seen those words and the error comes later, from inside the stream. Switching to the backup then would mean silently starting a different answer under text the user is reading. Treat it as a broken stream (Topic 3.3): keep what arrived, say it was cut off, and offer Retry, which goes through the fallback again. - 5
Tell the user which mode they are in. A backup model may be cheaper, shorter, less accurate or lack a feature. Put the mode in the message metadata (
{ mode: "backup" }on thefinishpart) and show a small label: "Backup mode: shorter answers while the main model recovers". Do not label it with a model name, which means nothing to a shopper. If a feature is *missing* in backup mode (for example, tools), disable its buttons instead of letting them fail. - 6
A circuit breaker stops you from hammering a provider that is clearly down. After a few failures in a row it "opens": for a while the main model is not called at all and everyone goes straight to the backup. Then it lets one trial request through; if that works the breaker closes, if not it opens again (third live example). A real breaker counts seconds. The example counts requests so the output is exactly repeatable.
- 7
A kill switch is a flag, kept outside your code (an environment variable that can be changed without a redeploy, or a config service), that your route checks first. When it is off, the route answers
503with a code such asai_pausedand calls no model. Use it when something is wrong *with you*: a runaway cost, a bad prompt shipped to everyone, a safety problem. Test it before you need it. - 8
Degraded mode is a designed screen, not an error. Both models down, or the kill switch on, should lead to a page that still helps: links to the FAQ, shipping and returns pages, the contact page, and a clear sentence. The second live example shows three screens from one error: normal, "Mitra is having trouble" with a retry, and "Mitra is switched off" with the Ask button disabled.
Explain it without notes
How would you implement a model fallback with the AI SDK, and where does it live?
Which errors should trigger the fallback, and which should not?
What does a circuit breaker add on top of a fallback?
What is a kill switch for, and what must the user see when it is on?
Practice
Add a timeout to the first example's main model so a model that takes more than 200 ms to start is treated as failed. Which scripted option of the stand-in makes the main model slow?
Add a second flag backupEnabled to the second example. When it is false and the main model is down, the page should show the "trouble" panel immediately, without trying a backup.
Trade-offs
- ↔
A backup model keeps the feature alive and costs extra work: another provider account, another set of behaviours to test, another place user text goes. A static help page is cheaper and always works, but it cannot answer the question.
- ↔
Aggressive breakers protect users from long waits and may cut over to the weaker model too soon. Tune from your own failure data.
Done when you can
I can wrap a model with
wrapLanguageModelso a retryable failure goes to a backup.I can say which errors should not trigger a fallback and why.
I can show the user which mode answered and disable features missing in backup mode.
I can explain a circuit breaker and its one trial request.
I can build a kill switch and a degraded page that still helps.