Antigravity × Multi-Provider LLM Failover — A Production Guide for Gemini, Claude, and Local Gemma
When Gemini returns 503, does your agent stop responding? This guide walks through a production-ready router, circuit breaker, and graded fallback design, with code you can paste in today.
Last month, the AI agent I run returned 503 errors from the Gemini API between 9 and 11 AM. Google Cloud's status page showed no incident; it took a search on X (Twitter) to confirm that a regional rollout was flaking intermittently.
The service didn't go down outright. But responses were peppered with errors, and more than a dozen users reached out asking if something was broken. A single morning erased trust I had spent six months building.
This article shares the full implementation I rebuilt after that morning — a "LLM stack that doesn't stop." Inside an Antigravity agent environment, we'll combine Gemini, Claude, and a local Gemma 4 model, with a router, circuit breaker, and graded fallback that hides partial outages from users. Every code sample is runnable as-is.
The reason I rebuilt this in earnest is that, as an indie developer, I run a mobile app business and the Dolice Labs sites in parallel. Ad delivery in the apps and the content pipelines behind the sites both lean on the same kind of external APIs underneath. When any one of them stalls, revenue and user trust erode at the same speed — and after feeling that a few times, I stopped treating resilience as an afterthought and started baking "it must not stop" into the first draft of every design. From here on, I'll turn that habit into concrete code and numbers.
When Multi-Provider Becomes Non-Negotiable
When people hear "LLM outages," they often picture something rare — the once-a-month global incident that shows up on status pages. Those do exist, but they're not what causes most user-visible failures.
Partial outages happen nearly every week in real operations:
503 errors spike in a single region while global metrics stay green
A rate-limit cap takes 3–5 minutes to clear even after traffic normalizes
A model becomes briefly pathological — p99 latency goes over 30 seconds
INTERNAL errors appear for specific token lengths only
A single-provider setup surfaces all of these directly to the user. The real value of multi-provider isn't avoiding total failure; it's absorbing partial failures so the user never notices. That framing matters because it changes what you optimize for — you're not building a second line of defense, you're building a shock absorber that fires dozens of times per day, invisibly.
Three Axes That Drive the Routing Decision
Before writing code, I want to lay out the decision framework I actually run in production. Three axes, evaluated in this order:
The capability axis comes first. Does this provider meet the minimum requirements of this request? If you need a 1M-token context, Gemini is your only choice. If you need complex tool-calling with nested JSON schemas, Claude wins. If you're offline, Gemma. Capability is a hard filter — a provider either qualifies or it doesn't.
The latency axis comes second. Among providers that qualify, prefer the one with lower p95 latency. One subtle but important rule: use a rolling median over the last 5 minutes, not the instantaneous response time. Spike-based routing causes oscillation, and your router ends up switching providers every other request.
The cost axis comes last. When capability and latency are comparable, pick the cheaper one. It has to be last because if you lead with cost, fallback breaks down — imagine a cheap provider that fails; you flip to the premium provider, but then the router keeps trying to return to cheap-and-broken. The order "capability → latency → cost" prevents this cycle.
Invert the order even slightly, and in six months you'll be staring at graphs trying to explain why your error rate hasn't improved. I've seen this firsthand.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Full router code that switches between Gemini, Claude, and local Gemma by capability, then latency, then cost
✦Circuit breaker and graded fallback implemented with production-tested configuration values
✦Three months of real metrics — fallback rate, p95, cost split — plus a reusable incident retro template
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Expected outcome: a provider implementing this interface (GeminiProvider, ClaudeProvider, GemmaLocalProvider below) can be invoked uniformly by the router as provider.invoke(req, signal).
Why include provider and attemptCount in LLMResponse? Because when something breaks in production, you need to know "which provider answered on which attempt." Without that audit trail, reproducing an incident is nearly impossible.
Here's a Gemini provider implementation. Claude and Gemma follow the same pattern.
Critical detail: always accept an AbortSignal in invoke and pass it to the SDK. Skip this and your timeouts become theater — sockets stay open, memory drifts upward, and you spend three days debugging what looks like a leak. I know, because I did.
Implementation 2: Circuit Breaker and Health Check
Swappable providers aren't enough. You also need a layer that refuses to keep hitting a broken provider. That's the circuit breaker.
// src/llm/circuit-breaker.ts// Purpose: track consecutive failures and skip a provider during cooldownexport type CircuitState = "closed" | "open" | "half-open";export interface CircuitBreakerConfig { failureThreshold: number; // fail N times in a row to trip openDurationMs: number; // how long to stay open before testing halfOpenSuccessCount: number; // successes required in half-open to close}export class CircuitBreaker { private state: CircuitState = "closed"; private consecutiveFailures = 0; private consecutiveHalfOpenSuccess = 0; private openedAt = 0; constructor(private readonly name: string, private readonly config: CircuitBreakerConfig) {} canExecute(): boolean { if (this.state === "closed") return true; if (this.state === "open") { if (Date.now() - this.openedAt >= this.config.openDurationMs) { this.state = "half-open"; this.consecutiveHalfOpenSuccess = 0; return true; } return false; } return true; // half-open: allow a limited trial } recordSuccess(): void { if (this.state === "half-open") { this.consecutiveHalfOpenSuccess++; if (this.consecutiveHalfOpenSuccess >= this.config.halfOpenSuccessCount) { this.reset(); } return; } this.consecutiveFailures = 0; } recordFailure(): void { if (this.state === "half-open") { // any failure in half-open pops us straight back to open this.state = "open"; this.openedAt = Date.now(); return; } this.consecutiveFailures++; if (this.consecutiveFailures >= this.config.failureThreshold) { this.state = "open"; this.openedAt = Date.now(); } } private reset(): void { this.state = "closed"; this.consecutiveFailures = 0; this.consecutiveHalfOpenSuccess = 0; } getState(): CircuitState { return this.state; }}
Why the half-open state: if you go straight from open back to closed, you dump full traffic onto a provider that may still be flaky. Half-open is a "limited trial" — if it holds, you close the circuit; if it flinches, you bounce back to open immediately. My production config: failureThreshold: 3, openDurationMs: 30000, halfOpenSuccessCount: 2.
A mistake I see often: people set failureThreshold: 10 thinking it's "safer." It isn't — your users hit 10 errors before the breaker trips. Three to five is the practical range.
Implementation 3: The Graded Fallback Router
This is the core. We tie the three axes, the providers, and the breaker together.
// src/llm/router.ts// Purpose: rank providers by capability → latency → cost, and fail over with breakersimport type { LLMProvider, LLMRequest, LLMResponse } from "./provider";import { CircuitBreaker } from "./circuit-breaker";interface ProviderSlot { provider: LLMProvider; breaker: CircuitBreaker; recentLatencyMs: number[];}export class LLMRouter { private slots: ProviderSlot[] = []; private readonly latencyWindow = 20; constructor(providers: LLMProvider[]) { this.slots = providers.map((p) => ({ provider: p, breaker: new CircuitBreaker(p.name, { failureThreshold: 3, openDurationMs: 30000, halfOpenSuccessCount: 2, }), recentLatencyMs: [], })); } async execute(req: LLMRequest, timeoutMs = 20000): Promise<LLMResponse> { const candidates = this.rankProviders(req); if (candidates.length === 0) { throw new Error("No capable provider available for this request"); } const errors: Array<{ provider: string; error: unknown }> = []; let attemptCount = 0; for (const slot of candidates) { if (!slot.breaker.canExecute()) continue; // skip open circuits attemptCount++; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await slot.provider.invoke(req, controller.signal); clearTimeout(timer); slot.breaker.recordSuccess(); this.recordLatency(slot, res.latencyMs); return { ...res, attemptCount }; } catch (err) { clearTimeout(timer); slot.breaker.recordFailure(); errors.push({ provider: slot.provider.name, error: err }); } } const detail = errors.map((e) => `${e.provider}: ${String(e.error)}`).join(" / "); throw new Error(`All providers failed after ${attemptCount} attempts — ${detail}`); } private rankProviders(req: LLMRequest): ProviderSlot[] { // Axis 1: capability filter const capable = this.slots.filter((s) => { if (req.jsonSchema && !s.provider.capabilities.supportsJsonSchema) return false; if (req.minContextTokens && req.minContextTokens > s.provider.capabilities.maxContext) return false; return true; }); // Axis 2 + 3: latency median, break ties by output cost return capable.sort((a, b) => { const diff = this.medianLatency(a) - this.medianLatency(b); if (Math.abs(diff) > 500) return diff; // >500ms: latency decides return a.provider.cost.outputPer1M - b.provider.cost.outputPer1M; }); } private medianLatency(slot: ProviderSlot): number { if (slot.recentLatencyMs.length === 0) return 1500; const sorted = [...slot.recentLatencyMs].sort((a, b) => a - b); return sorted[Math.floor(sorted.length / 2)]; } private recordLatency(slot: ProviderSlot, latencyMs: number): void { slot.recentLatencyMs.push(latencyMs); if (slot.recentLatencyMs.length > this.latencyWindow) slot.recentLatencyMs.shift(); }}
Expected outcome: router.execute(req) ranks capable providers by latency then cost, attempts each whose breaker is closed, and throws only after all fail — with a single error message that names every provider and its cause.
Using it is straightforward:
// src/main.tsimport { GeminiProvider } from "./llm/gemini-provider";import { ClaudeProvider } from "./llm/claude-provider";import { GemmaLocalProvider } from "./llm/gemma-local-provider";import { LLMRouter } from "./llm/router";const router = new LLMRouter([ new GeminiProvider(process.env.GEMINI_API_KEY!), new ClaudeProvider(process.env.ANTHROPIC_API_KEY!), new GemmaLocalProvider("http://localhost:1234/v1"), // LM Studio]);const res = await router.execute({ messages: [{ role: "user", content: "Generate a product description as JSON" }], jsonSchema: { type: "object", properties: { name: { type: "string" }, description: { type: "string" } } }, maxTokens: 500,});console.log(`Provider: ${res.provider}, Attempts: ${res.attemptCount}, Latency: ${res.latencyMs}ms`);// e.g. "Provider: claude, Attempts: 2, Latency: 1843ms"// One line tells you Gemini failed and Claude picked up.
The skeleton is done. But sloppy timeout and retry settings will undo it all. Four pitfalls I've personally walked into.
Pitfall 1: timeouts that are too long. A 60-second global timeout means users wait 59 seconds on Gemini before the router even tries Claude. Set per-provider timeouts to about 1.5× the observed p95. If Gemini's p95 is 8 seconds, set 12.
Pitfall 2: doubling up on SDK-level retries. The official Google and Anthropic SDKs retry 2–3 times internally by default. Layered on top of your router retries, users end up experiencing up to 9 sequential attempts. Disable SDK retries or cap them at 1. For Anthropic: maxRetries: 0.
Pitfall 3: streaming + timeouts don't mix naturally. A streaming response needs two timeouts — time-to-first-byte and total-duration. If you only set total-duration, legitimate long answers get guillotined mid-sentence.
Pitfall 4: notifying users about fallback at the wrong moment. Announcing "something failed, we're trying another model" immediately gives users the impression that something broke. In my production setup, I only surface "switching to a different model" if the first attempt has been running more than 3 seconds — otherwise the swap happens silently.
Four Real Production Problems
Once the code ships, the next 2–3 weeks reveal problems the design didn't foresee. This is the part of the article I'd have paid to read a year ago.
Problem 1: slow local Gemma health checks stalling routing.
Early on, I ran health checks synchronously in sequence. Gemma (LM Studio) takes about 10 seconds to warm up on a cold start, which added 10 seconds to every routing decision. Fix: run health checks fully async in the background on a 60-second interval and cache the result.
Problem 2: circuit breaker state resets on process restart.
Running 10 load-balanced instances, every restart resets one breaker to closed, sending 3 requests to a still-unstable provider before it trips again. Solution: persist breaker state in Redis. Pair it with a shared caching layer like Cloudflare AI Gateway and operations get much cleaner.
Problem 3: structured-output requests skew traffic.
In a product with lots of JSON-schema requests, Gemma local never sees traffic — defeating the point of local capacity. You can either add more non-schema requests or write a JSON-schema shim for Gemma. I took the former route by making several prompts tolerate plain text responses.
Problem 4: cost accounting drift.
Your in-app cost totals will disagree with provider dashboards by 3–5%. The cause is tokenizer differences — the same text tokenizes differently across Gemini, Claude, and Gemma. Treat internal totals as estimates; trust the provider dashboards for billing reconciliation.
Monitoring and Alerts
Finally, the dashboards. At minimum, track these five metrics:
Success rate per provider (rolling 5-minute mean)
p95 latency per provider
Fallback rate (share of requests with attemptCount > 1)
Circuit open events (time series)
Daily cost (by provider and by use case)
Two alerts are non-negotiable: "success rate below 95% for 5 minutes" and "circuit stuck open for over 10 minutes." The first catches partial degradation; the second catches sustained outages.
A subtle but useful tell: when fallback rate climbs above 20%, it's a signal that your primary provider choice is wrong. That's the moment to revisit whether the "best" model deserves primary status.
For deeper observability, I recommend pairing this with Langfuse-based agent observability. Per-provider traces plus request-level tracing give you "what happened on this request, and why" in a single pane.
A Provider Selection Cheat Sheet
Re-reading the router code every time isn't practical. I keep the table below at hand and use it to provisionally decide "which provider goes primary" before writing a line of code for any new use case.
Dimension
Gemini (Flash)
Claude
Local Gemma 4
Best role
First pass on long context / large inputs
Complex tool calls / strict JSON
Offline, sensitive data, cost relief
Context limit
~1M tokens
~200K tokens
Depends on the quantized model (8k–128k)
Perceived stability
Has windows where partial outages cluster
Fairly stable, latency wobbles
Self-hosted, so zero external factors
When to make it primary
Huge input payloads
Structured output is mandatory
No network, or cost above all
As a fallback
Catches Claude outages
Catches Gemini outages
Last line when both clouds wobble at once
The point of this table is the policy "always reserve one last-line slot for local Gemma." The odds of both clouds wobbling simultaneously are low, but I myself once hit rate limits on both during the same evening window. A slightly lower-quality answer that still returns is far kinder to users than a silent error.
Three Months of Real Numbers
A design is only proven in operation. After running the setup above in production for three months, here are representative figures pulled from the dashboard. The values shift with product characteristics, but they give a sense of scale.
Metric
Before (single provider)
After (three providers)
User-visible error rate
~1.8%
~0.12%
Fallback rate (attemptCount > 1)
—
~6%
p95 latency
~8.2s
~8.9s
Monthly LLM cost
baseline
+4% vs. baseline
Share handled by local Gemma
—
~9%
Notice that the user-visible error rate dropped by more than an order of magnitude while p95 latency barely moved. The numbers show that fallback isn't a tax on every request; it's a shock absorber that quietly fires on just 6% of them. Cost staying at +4% is largely thanks to offloading ~9% of traffic to local Gemma.
One honest note: in the first month, the fallback rate spiked to 18%. The cause was a primary-selection mistake — I had Gemini primary for a use case heavy in structured output. The moment I swapped Claude to primary, it settled to 6%. The cheat-sheet row "Claude primary when structured output is mandatory" was written out of that failure.
An Incident Retro Template — So the Next Morning Isn't a Repeat
Even with the machinery in place, outages return in new shapes. What matters is reliably converting each one into a lesson. On days when fallbacks spike, I always leave a short record using the template below. It doesn't need to be long; keeping it to a 15-minute fill is what makes the habit stick.
## Incident note YYYY-MM-DD### What happened (facts only)- Start / recovery time:- Affected provider(s):- Peak fallback rate:- User-visible error count:### How detection worked- First signal (alert / support ticket / my own eyes):- Did the alert fire as expected:### How the router behaved- Did the circuit open / after how many seconds:- Was the fallback target appropriate:- Any unexpected behavior:### One next step (only one)- Exactly one of: config change / code fix / new monitoring:
What makes this template work is the final constraint: "exactly one next step." Right after an outage you can see a mountain of things to fix, but trying to fix them all at once leaves every one half-done. I've handled plenty of incidents around the ad stack in my wallpaper apps, and the setups I hardened "one fix per night" always ended up sturdier than the ones I overhauled in a burst. Failover design is the same — the accumulation of small fixes carved out in operation becomes the most trustworthy defense in the end.
Closing — One Small Step You Can Take Today
Multi-provider architecture is one of those topics everyone knows they should implement "someday." The problem is that someday never comes while you're shipping features.
Start here, today: force a 503 from your current single provider and observe what your agent does.
Rewrite your API key to something invalid and run a request. Watch the error propagate. You'll get a visceral sense — not a metric-based sense — of why fallback matters.
From there, use the code in this article as scaffolding and tune the axes to your product's request profile. Don't aim for perfect coverage on day one; that path is the one I tried, and it failed twice before I learned to ship incrementally.
Services staying up is invisible to users. Building the thing that keeps them up is one of the most durable skills for an AI-era engineer — and worth the afternoon it takes to start.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.