On October 28, an internal tool I wrote for my own prospecting stopped drafting. The provider was not down. My claude.ai login had expired. Every call failed with "401 OAuth access token has expired". An API key was configured right next to it. My code never tried it.
TL;DR: an AI provider goes down, changes its quotas or refuses your login. Your product must not go down with it. Send every AI call through one router. Give it several backends in a stable order. When a backend refuses the login, bench it for ten minutes. The next one takes over, and nobody has to redeploy.
Incidents and versions: snapshot as of November 3, 2026.
This article is for developers and product teams whose code calls a language model.
The provider lets you down more often than you think
Look at the history on status.claude.com. On October 24 and 25, it lists five "Elevated errors" incidents on several models. Each one lasted between ten and thirty-five minutes. Thirty minutes is short for a provider. For your user, it is a button that stops answering.
On October 1, the incident was of another kind. Credit purchases were slow to show up in account balances. Requests failed for "insufficient credit balance". The incident stayed open from 16:20 to 22:36 UTC. Your code had no bug. It got errors anyway.
The Claude API documentation lists the cases. A 401 means your key is malformed, revoked or expired. A 429 means you hit a rate limit or a spend cap. A 529 means the API is overloaded. Three different causes, one result: your feature returns nothing.
A serious product needs a degraded mode. That is a state where your product delivers a reduced service instead of an error. For an AI feature, the simplest degraded mode is another provider.
On October 28, my router picked the backend that no longer worked
My tool runs on a Rust backend. It calls a model to classify messages, qualify emails, draft replies and summarize threads. An AI backend here is one model provider my code knows how to call. The tool knows four: the Claude Code CLI, the Anthropic API, the OpenAI API and a local Ollama.
The Claude Code CLI is the default backend. It bills my subscription, not each request. It is the cheapest provider for a tool only I use.
That morning, my claude.ai login had expired. An OAuth login is a time-limited authorization. When it expires, a human has to sign in again. The CLI wrote the error inside its JSON stream on standard output. Then it exited with code 1 and an empty standard error.
My router did not look at that result. It saw the CLI installed, so available. It picked it on every call. Drafts, rewrites and CV and dossier generation all broke. An Anthropic API key was configured in the settings. It was never used.
The fix comes down to two ideas. A refused login benches the CLI for ten minutes. And the router moves on to the next backend while the bench lasts.
The backend that refuses the login is benched, the next one takes over. Every feature goes through the router. A timeout or a rate limit benches nothing.
One entry point for every AI call
Every AI feature in the tool goes through one method: Clients::ai(settings). It returns the backend to use for this call. Nothing calls a provider directly. That is what makes the fix possible: a rule written once protects every feature.
The ai_provider setting picks the primary provider. The others follow in a stable order:
let order: [&str; 4] = match settings.ai_provider.as_str() { "anthropic_api" => ["anthropic_api", "claude_code", "openai", "ollama"], "openai" => ["openai", "claude_code", "anthropic_api", "ollama"], "ollama" => ["ollama", "claude_code", "anthropic_api", "openai"], // Default: the Claude Code CLI (subscription billing). _ => ["claude_code", "anthropic_api", "openai", "ollama"], }; Every client is optional. Without its environment variable, the client does not exist and the feature is off. Boot logs it once, then stays quiet. Ollama only joins the rotation if a five-second ping succeeded at boot.
A sibling method, ai_availability(), tells the settings screen which backends are usable right now. The user sees what the router sees. A provider that is down is not a surprise at click time.
This router is part of the harness, the code around the model. I covered it in the article on the harness: the harness decides what the model sees. Here, it also decides which model answers.
Only a refused login benches a backend
The CLI client keeps one date: the last time its login was refused. Two constants and one function are enough:
/// Hard cap on a CLI invocation. const EXEC_TIMEOUT: Duration = Duration::from_secs(600); /// How long the router leaves the CLI out after its login was refused. const AUTH_BENCH: Duration = Duration::from_secs(600); /// True when the CLI failed because its claude.ai login is missing or expired. fn is_auth_failure(error: &str) -> bool { error.contains("Failed to authenticate") } signed_in() returns false for AUTH_BENCH after a refusal. The router skips the CLI while it is false. The next backend, usually the API key, takes over. A successful call clears the date. The CLI is back in the rotation without a restart.
Only one kind of error triggers the bench. A timeout or a rate limit does not. The reason is simple. A timeout may pass on the next call. A rate limit lifts on its own. A refused login does not: it will refuse every call until a human signs in again.
That is why the bench lasts ten minutes. Long enough to cover the calls coming in. Short enough to retry without anyone stepping in. It is a circuit breaker cut down to one case. I did not count errors or compute a rate. A date and a comparison are enough.
The test replays the real outage
The unit test replaces the CLI with a shell script. The script mimics what the real CLI returned on October 28. A JSON result with the error message, then exit code 1.
#!/bin/sh if [ -f ok ]; then printf '%s\n' '{"result":"fine"}'; exit 0; fi printf '%s\n' '{"type":"result","result":"Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue."}' exit 1 The test an_expired_login_benches_the_cli_until_a_run_succeeds does three things. It checks that the client believes it is signed in at first. It runs the script, sees the error and checks that signed_in() turned false. Then it creates the ok file, runs the script again and checks that signed_in() is true again.
A second test, other_cli_failures_keep_the_cli_in_the_rotation, protects the other half of the rule. It feeds three messages to is_auth_failure. The message "Failed to authenticate. API Error: 401 OAuth access token has expired." returns true. "claude timed out after 600 s" returns false. "rate limit reached" returns false too.
Here is the useful part. The test holds the real output of the real incident. If the CLI ever changes its message, the test breaks before my product does.
The same rule in Go
This blog is mostly about Go, so here is the same pattern in Go. An interface for the backends, a slice in a stable order, one bench date per backend.
// Backend is one AI provider the product knows how to call. type Backend interface { Name() string Generate(ctx context.Context, system, user string) (string, error) } // ErrAuth marks a refused login. Only this error benches a backend. var ErrAuth = errors.New("login refused") type Router struct { backends []Backend // stable order, primary first bench time.Duration mu sync.Mutex until map[string]time.Time // backend name -> benched until } func NewRouter(bench time.Duration, backends ...Backend) *Router { return &Router{backends: backends, bench: bench, until: map[string]time.Time{}} } func (r *Router) Generate(ctx context.Context, system, user string) (string, error) { err := errors.New("no AI backend available") for _, b := range r.backends { if r.benched(b.Name()) { continue } var out string out, err = b.Generate(ctx, system, user) if err == nil { r.set(b.Name(), time.Time{}) // a success clears the bench return out, nil } if errors.Is(err, ErrAuth) { r.set(b.Name(), time.Now().Add(r.bench)) } } return "", err } func (r *Router) benched(name string) bool { r.mu.Lock() defer r.mu.Unlock() return time.Now().Before(r.until[name]) } func (r *Router) set(name string, until time.Time) { r.mu.Lock() defer r.mu.Unlock() r.until[name] = until } Each adapter translates its own refusal into ErrAuth. For the CLI, that is the "Failed to authenticate" message. For the API, that is a 401 authentication_error. The adapter returns fmt.Errorf("%w: %v", ErrAuth, err). The router never needs to know the providers.
This version goes one step further than my Rust. When a backend fails, it tries the next one in the same call. My tool returns the error of the first refused call. Only the following calls go to the API key. Both work. The Go version avoids one visible error.
A gateway is one more dependency
You can also buy this router. An LLM gateway talks to dozens of providers, measures costs and fails over on its own. That is useful when you have many providers and many teams.
But a gateway sees every one of your keys go by. On October 21, 2026, version 1.107.0 of LiteLLM, one of those gateways, was published on PyPI with a credential stealer. It collected system, browser, cloud and AI tool secrets, then sent them to an external domain. The version was pulled in under three hours. Anyone who installed it in that window has to rotate every key.
The suspected cause deserves a pause. According to LiteLLM, a coding agent could write the user's GitHub token into a response body. And the gateway logged those response bodies. The token ended up in the logs. The component that centralizes your AI calls also centralizes what leaks.
This is not a verdict against gateways. It is a reminder: every component between your code and the provider is a dependency that holds your secrets. My tool has four clients and a router of about sixty lines. For a product with two or three backends, that is often enough.
And the less you call the model, the less you depend on the provider. I wrote about why you should put the LLM last in the pipeline. A router protects the calls that remain.
The checklist
An AI provider is a dependency that fails. Code as if that were true.
- [ ] Send every AI call through one function
- [ ] Declare the backends in a list, in a stable order
- [ ] Make every backend optional: no key, no client, one log line at boot
- [ ] Detect the refused login: 401, "Failed to authenticate", expired key
- [ ] Bench that backend for a fixed time, and skip it in the router
- [ ] Never bench on a timeout or a rate limit: retry
- [ ] Put the backend back in play as soon as one call succeeds
- [ ] Show the user which backends are available right now
- [ ] Replay the real output of the real outage in a test
The takeaway
An AI provider goes down, changes its quotas or refuses your login. The question is not if, but when. On that day, your code has two options: return an error, or move to the next backend. The second one costs a router, a list and a date.
Open your code and look for the places that call a provider directly. Each one is a feature that will go down with it.
Want an outside look at the reliability of your AI features? See my services, or let's talk.
Sources: Incident history, status.claude.com · Incident "Delayed credits on the Claude Platform", October 1, 2026 · Incident "Elevated errors for Claude Sonnet 5.5 and other models", October 25, 2026 · Claude API documentation, error codes · The Hacker News, LiteLLM 1.107.0 compromise (October 22, 2026)

![Industrial Communication — Ch.10: Factory Automation [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1hw2kvh64lkchmsurzd.png)

