Last updated 2026-05-17
Multi-provider Failover
A common ask: when OpenAI returns 429 or a 5xx, try Anthropic; if
that also fails, surface the original error. This page shows how to
build a Failover wrapper that implements the
llmrouter.Provider
interface — so the rest of your code never knows it's there — and
walks through the subtleties that make this less trivial than it
looks.
Goal
Build a single llmrouter.Provider that wraps a slice of
underlying providers. On retriable errors, it iterates to the next
one. The caller's code stays unchanged:
primary, _ := openai.New(llmrouter.WithAPIKey(openaiKey))backup, _ := anthropic.New(llmrouter.WithAPIKey(anthropicKey))
p := Failover{ providers: []llmrouter.Provider{primary, backup}, mapModel: defaultModelMap,}
// Same surface as any other Provider:stream, err := p.CompletionStream(ctx, req)Why this isn't in the library
Failover policy is not portable. Different teams disagree on:
- Which errors are retriable. 429 yes; 500 yes; 400 probably not (it's a client bug, not an upstream blip); 401 never — that's an auth misconfig and another provider won't fix it.
- How many providers to try. Two? Three? Are they ordered by cost? By latency? By regional locality?
- Whether the models are semantically interchangeable.
gpt-4o-miniandclaude-3-5-haiku-latestare roughly comparable for chat, but they have different tool-use formats, different vision support, different context windows, and meaningfully different prose styles. Failover that silently swaps one for the other can produce confusing UX.
A baked-in llmrouter.NewFailover would either be too
opinionated for some users or too generic to be useful. Instead, the
library gives you a clean interface and lets you write the 60 lines
of policy that match your app.
The shape of a Failover wrapper
// Failover wraps an ordered list of providers. CompletionStream tries// each in turn; on a retriable error from one, it moves to the next.// The last error is returned if every provider fails.type Failover struct { providers []llmrouter.Provider // mapModel translates the requested model id for provider at index // attempt. attempt 0 is the primary (usually identity), attempt 1 // is the backup, etc. mapModel func(model string, attempt int) string}
// Name returns a composite id like "failover[openai,anthropic]" so// metrics and logs can distinguish the wrapper from its members.func (f Failover) Name() string { names := make([]string, 0, len(f.providers)) for _, p := range f.providers { names = append(names, p.Name()) } return "failover[" + strings.Join(names, ",") + "]"}
// CompletionStream tries each provider until one succeeds at opening// the stream. Once a stream is open, no failover happens — see the// caveat below.func (f Failover) CompletionStream(ctx context.Context, req llmrouter.ChatRequest) (*llmrouter.Stream, error) { if len(f.providers) == 0 { return nil, errors.New("failover: no providers configured") } var lastErr error for attempt, provider := range f.providers { // Translate the model id for this attempt. The primary // typically gets the user's original id; backups get whatever // mapModel returns. req.Model = f.mapModel(req.Model, attempt)
stream, err := provider.CompletionStream(ctx, req) if err == nil { return stream, nil } lastErr = err if !isRetriable(err) { return nil, err } // Honour cancellation in the loop too. if ctx.Err() != nil { return nil, ctx.Err() } } return nil, lastErr}Defining "retriable"
The interesting decision is which errors trigger a failover. Look at
*llmrouter.ErrUpstream.StatusCode first; for network
errors (the upstream never answered), the wrapped error won't be an
*ErrUpstream — it'll be a plain error
with a message like "openai: http: dial tcp: …".
// isRetriable decides whether to fall over to the next provider.//// We retry on:// - 429 (rate limit / quota)// - 5xx (upstream having a bad day)// - network errors (no response at all)//// We do NOT retry on:// - 400 — the request body is wrong; the next provider will reject it too// - 401, 403 — auth misconfig; failing over hides the real problem// - 404 — model not found; mapModel didn't pick a real id// - context cancellation — the caller told us to stopfunc isRetriable(err error) bool { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } var upstream *llmrouter.ErrUpstream if errors.As(err, &upstream) { switch upstream.StatusCode { case 429, 500, 502, 503, 504: return true } return false } // Not an *ErrUpstream — likely a network failure. Retry. return true}Caveat: failover is impossible mid-stream
By the time the upstream has returned a 200 and you've started
streaming bytes back to your client, it's too late. You've already
sent data: {...} chunks downstream — switching to a
different provider mid-stream would produce gibberish (a different
chat id, a non-contiguous narrative, possibly a different model
field).
For that reason, CompletionStream in this wrapper only
fails over when the initial request fails — i.e.
before any chunks have arrived. If a stream dies after the first
chunk, the error propagates to the caller and they decide what to do
(typically: give up and retry the whole request at a higher level).
Model mapping
The primary handles gpt-4o-mini natively. When we fail
over to Anthropic, that id is meaningless — we need to translate to
a comparable Anthropic model.
// Translate the requested model id by attempt index. Attempt 0 is// usually identity (use the model the caller asked for). Higher// attempts map to comparable models on the backup providers.//// IMPORTANT: this is a coarse approximation. gpt-4o-mini and// claude-3-5-haiku-latest are similar in cost/latency/quality but not// identical. If your app cares about token-level reproducibility, do// NOT use failover — fail loud instead.func defaultModelMap(model string, attempt int) string { if attempt == 0 { return model } // Attempt 1 = Anthropic backup. switch model { case "gpt-4o-mini": return "claude-3-5-haiku-latest" case "gpt-4o": return "claude-sonnet-4-5" case "o1-mini": return "claude-3-5-haiku-latest" } // Fall back to a safe default rather than passing an OpenAI id to // Anthropic (which would 404). return "claude-3-5-haiku-latest"}The complete program
package main
import ( "context" "errors" "fmt" "log" "os" "strings"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/anthropic" "github.com/elloloop/llmrouter/providers/openai")
type Failover struct { providers []llmrouter.Provider mapModel func(model string, attempt int) string}
func (f Failover) Name() string { names := make([]string, 0, len(f.providers)) for _, p := range f.providers { names = append(names, p.Name()) } return "failover[" + strings.Join(names, ",") + "]"}
func (f Failover) CompletionStream(ctx context.Context, req llmrouter.ChatRequest) (*llmrouter.Stream, error) { if len(f.providers) == 0 { return nil, errors.New("failover: no providers configured") } originalModel := req.Model var lastErr error for attempt, provider := range f.providers { req.Model = f.mapModel(originalModel, attempt) stream, err := provider.CompletionStream(ctx, req) if err == nil { log.Printf("failover: %s served %s (attempt %d)", provider.Name(), req.Model, attempt) return stream, nil } log.Printf("failover: %s failed (attempt %d): %v", provider.Name(), attempt, err) lastErr = err if !isRetriable(err) { return nil, err } if ctx.Err() != nil { return nil, ctx.Err() } } return nil, lastErr}
func isRetriable(err error) bool { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } var upstream *llmrouter.ErrUpstream if errors.As(err, &upstream) { switch upstream.StatusCode { case 429, 500, 502, 503, 504: return true } return false } return true}
func defaultModelMap(model string, attempt int) string { if attempt == 0 { return model } switch model { case "gpt-4o-mini": return "claude-3-5-haiku-latest" case "gpt-4o": return "claude-sonnet-4-5" } return "claude-3-5-haiku-latest"}
func main() { primary, err := openai.New(llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) if err != nil { log.Fatal(err) } backup, err := anthropic.New(llmrouter.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))) if err != nil { log.Fatal(err) }
p := Failover{ providers: []llmrouter.Provider{primary, backup}, mapModel: defaultModelMap, }
ctx := context.Background() stream, err := p.CompletionStream(ctx, llmrouter.ChatRequest{ Model: "gpt-4o-mini", Messages: []llmrouter.Message{ llmrouter.TextMessage("user", "in one short sentence, what is a goroutine?"), }, MaxTokens: 100, }) if err != nil { log.Fatal(err) }
for chunk := range stream.Chunks() { for _, c := range chunk.Choices { fmt.Print(c.Delta.Content) } } fmt.Println() if err := stream.Err(); err != nil { log.Printf("stream error: %v", err) }}Testing the policy without a network
Because Provider is an interface, the failover wrapper
is trivially unit-testable. Build two fake providers — one that
returns 429, one that succeeds — and verify the wrapper falls
through.
package main
import ( "context" "errors" "io" "strings" "testing"
"github.com/elloloop/llmrouter")
// fakeProvider is a Provider that returns a canned error or a tiny// stream depending on configuration.type fakeProvider struct { name string err error // if non-nil, returned by CompletionStream body string}
func (f *fakeProvider) Name() string { return f.name }
func (f *fakeProvider) CompletionStream(ctx context.Context, req llmrouter.ChatRequest) (*llmrouter.Stream, error) { if f.err != nil { return nil, f.err } stream, _, hooks := llmrouter.NewStream(ctx) go func() { hooks.Send(llmrouter.Chunk{ Choices: []llmrouter.Choice{{ Delta: llmrouter.Delta{Content: f.body}, }}, }) hooks.Finish(nil) }() return stream, nil}
func TestFailover_FallsThroughOn429(t *testing.T) { primary := &fakeProvider{ name: "p1", err: &llmrouter.ErrUpstream{Provider: "p1", StatusCode: 429, Body: "rate limit"}, } backup := &fakeProvider{name: "p2", body: "ok"}
f := Failover{ providers: []llmrouter.Provider{primary, backup}, mapModel: func(m string, _ int) string { return m }, } stream, err := f.CompletionStream(context.Background(), llmrouter.ChatRequest{Model: "x"}) if err != nil { t.Fatalf("unexpected error: %v", err) } got, _ := io.ReadAll(streamReader(stream)) if !strings.Contains(string(got), "ok") { t.Fatalf("expected backup body, got %q", got) }}
func TestFailover_StopsOn400(t *testing.T) { primary := &fakeProvider{ name: "p1", err: &llmrouter.ErrUpstream{Provider: "p1", StatusCode: 400, Body: "bad request"}, } backup := &fakeProvider{name: "p2", body: "should not be reached"}
f := Failover{ providers: []llmrouter.Provider{primary, backup}, mapModel: func(m string, _ int) string { return m }, } _, err := f.CompletionStream(context.Background(), llmrouter.ChatRequest{Model: "x"}) var up *llmrouter.ErrUpstream if !errors.As(err, &up) || up.StatusCode != 400 { t.Fatalf("expected 400 ErrUpstream, got %v", err) }}
// streamReader drains a Stream into an io.Reader for test ergonomics.func streamReader(s *llmrouter.Stream) io.Reader { var b strings.Builder for chunk := range s.Chunks() { for _, c := range chunk.Choices { b.WriteString(c.Delta.Content) } } return strings.NewReader(b.String())}When failover is the wrong tool
- Tool-use / function-calling. OpenAI and Anthropic use different schemas. If your client expects OpenAI-shape tool calls and the backup is Anthropic, you'll get back something the client can't parse.
- Vision / multimodal inputs. The two providers
have different content-array shapes. Passthrough mode (using
ChatRequest.Raw) hides this; the Anthropic provider does best-effort conversion, but corner cases will bite. - Strict reproducibility. If a downstream system caches by prompt+model — or evaluates output against a golden file — silently swapping models invalidates the cache and breaks the evals.
- Cost accounting. Token pricing differs. A failover that quietly moves traffic to a more expensive backup during a 429 storm can torch your budget.
Related
- Build a chat gateway
— drop the
Failoverwrapper into the gateway handler and your routes survive provider outages. - Custom HTTP client & retries — for in-provider retries with exponential backoff, before escalating to a different provider.
- The provider interface — the contract that makes wrapping providers like this possible.