Last updated 2026-05-17
Error Handling
llmrouter surfaces errors from five distinct places.
Knowing which is which is the difference between a retry loop that
rescues a flaky network and a retry loop that hammers a permanently
misconfigured endpoint. This page walks the taxonomy, the sentinel
and wrapper types, and the patterns for handling each.
Where errors come from
| Source | Returned from | Retryable? |
|---|---|---|
| Configuration | provider.New(...) | No — fix the config |
| Request build | CompletionStream | No — fix the request |
| Network | CompletionStream | Yes (with care) |
| Upstream HTTP | CompletionStream | Depends on status code |
| Stream read | Stream.Err() | Depends — usually no |
The two functions that produce errors are
CompletionStream (synchronous error on open) and
Stream.Err() (terminal error after the producer
goroutine finishes). Treating those as the only error checkpoints
is the simplest mental model:
stream, err := p.CompletionStream(ctx, req)if err != nil { // configuration, request-build, network, or upstream HTTP error}for chunk := range stream.Chunks() { // ...}if err := stream.Err(); err != nil { // network read failure during streaming, or context cancellation}ErrInvalidConfig — configuration errors
var ErrInvalidConfig = errors.New("invalid provider config")
A sentinel returned (wrapped) from provider.New when an
option fails validation or the provider rejects the final
configuration. Match it with errors.Is:
p, err := openai.New(llmrouter.WithAPIKey(""))if errors.Is(err, llmrouter.ErrInvalidConfig) { log.Fatalf("invalid configuration: %v", err)}
Causes include empty API key, malformed base URL, negative
timeout, and provider-specific requirements (the Anthropic provider
requires an API key; future Azure/Bedrock providers will require
keys from WithExtra).
These errors are eager: you see them at construction time, not at request time. That's deliberate — config errors should fail fast at startup, not at 3 AM under load.
*ErrUpstream — upstream HTTP errors
type ErrUpstream struct { Provider string StatusCode int Body string}
func (e *ErrUpstream) Error() string { return fmt.Sprintf("%s upstream %d: %s", e.Provider, e.StatusCode, e.Body)}
Returned synchronously from CompletionStream when the
upstream responds with status >= 400, before any SSE frames have
been read. Match it with errors.As:
var ue *llmrouter.ErrUpstreamif errors.As(err, &ue) { log.Printf("provider=%s status=%d body=%s", ue.Provider, ue.StatusCode, ue.Body)}The body is truncated for log safety:
- OpenAI provider — up to 1KB.
- Anthropic provider — up to 8KB.
Truncation happens because some upstreams return HTML error pages,
multi-megabyte stack traces, or other noise on failure. The
truncated snippet is enough to debug the typical
{"error":{"message":"..."}} case without blowing
up your logs.
Distinguishing upstream errors
The StatusCode field is your routing key:
| Status | Meaning | Action |
|---|---|---|
400 | Malformed request, unknown model, content policy | Fail — bad input |
401 | Invalid API key | Fail loudly — config issue |
403 | Key valid but not authorized for this resource | Fail — permission issue |
404 | Model not found, wrong base URL | Fail — config issue |
408 | Request timeout (rare for chat completions) | Retry with backoff |
429 | Rate-limited or quota exhausted | Retry with backoff; honor Retry-After if present |
500 / 502 / 503 / 504 | Upstream server error | Retry with backoff (bounded) |
A simple classifier:
func isRetryable(err error) bool { var ue *llmrouter.ErrUpstream if !errors.As(err, &ue) { // Not an upstream error — could be network, config, etc. return false } switch ue.StatusCode { case 408, 429, 500, 502, 503, 504: return true default: return false }}Retrying transient errors
The most reliable place to retry is inside the
http.RoundTripper — it sits below the streaming layer,
so it can retry on the initial response status (when no bytes have
been delivered to the caller yet) without re-running an
in-progress completion.
package main
import ( "errors" "fmt" "io" "math/rand" "net/http" "strconv" "time"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/openai")
// retryTransport retries on 429 and 5xx with exponential backoff// and jitter. Stops retrying as soon as the body starts streaming —// at that point the upstream has committed tokens to the response.type retryTransport struct { base http.RoundTripper maxAttempts int}
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { for attempt := 1; ; attempt++ { resp, err := t.base.RoundTrip(req) // Network error: try again unless we're out of attempts. if err != nil { if attempt >= t.maxAttempts { return nil, err } if !sleep(req, backoff(attempt)) { return nil, req.Context().Err() } continue } // Don't retry success or non-retryable failures. if resp.StatusCode < 400 || (resp.StatusCode != 429 && resp.StatusCode < 500) { return resp, nil } // Retryable error. Drain and close to free the connection. _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() if attempt >= t.maxAttempts { return nil, fmt.Errorf("after %d attempts: %s", attempt, resp.Status) } wait := retryAfter(resp.Header) if wait == 0 { wait = backoff(attempt) } if !sleep(req, wait) { return nil, req.Context().Err() } }}
func backoff(attempt int) time.Duration { base := time.Duration(1<<attempt) * 500 * time.Millisecond jitter := time.Duration(rand.Int63n(int64(base) / 2)) return base + jitter}
func retryAfter(h http.Header) time.Duration { v := h.Get("Retry-After") if v == "" { return 0 } if secs, err := strconv.Atoi(v); err == nil { return time.Duration(secs) * time.Second } return 0}
func sleep(req *http.Request, d time.Duration) bool { select { case <-time.After(d): return true case <-req.Context().Done(): return false }}
// Compose into a provider.func newProvider(apiKey string) (llmrouter.Provider, error) { client := &http.Client{ Timeout: 5 * time.Minute, Transport: &retryTransport{ base: http.DefaultTransport, maxAttempts: 4, }, } return openai.New( llmrouter.WithAPIKey(apiKey), llmrouter.WithHTTPClient(client), )}
// Suppress unused: errors import for the larger example below.var _ = errors.NewStream errors
A stream that opened successfully may still fail later. The
producer goroutine reports any terminal failure through
Stream.Err():
for chunk := range stream.Chunks() { // ...}if err := stream.Err(); err != nil { // possibilities: // - context.Canceled (caller called cancel or stream.Cancel) // - context.DeadlineExceeded (request context deadline fired) // - network read error (wrapped, e.g. "openai: read stream: ...") // - upstream protocol error (SSE buffer overflow, malformed frame)}The OpenAI provider wraps read errors like this:
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) { hooks.Finish(fmt.Errorf("openai: read stream: %w", err)) return}And the Anthropic provider:
if err := scanner.Err(); err != nil && err != io.EOF { hooks.Finish(fmt.Errorf("anthropic: read stream: %w", err)) return}Stream errors are not safely retryable from outside the stream — you've already shipped some chunks to the caller, and you can't un-ship them. The right response is usually to surface the error and let the caller (a UI, an upstream gateway) decide whether to start a new request.
Context-cancelled errors
Two well-known sentinels surface from Stream.Err():
-
context.Canceled— caller cancelled the parent context, or calledstream.Cancel(). Treat as expected; not an error to log noisily. -
context.DeadlineExceeded—context.WithTimeoutorcontext.WithDeadlinefired. The request ran out of budget. Treat as a soft failure; possibly retry with a longer budget.
switch {case errors.Is(err, context.Canceled): // intentional — silentcase errors.Is(err, context.DeadlineExceeded): log.Printf("request exceeded deadline")case err != nil: log.Printf("stream failed: %v", err)}Structured logging
For production, log upstream errors with structured fields so you can build alerts and dashboards on them:
import "log/slog"
func logErr(ctx context.Context, err error) { var ue *llmrouter.ErrUpstream switch { case errors.As(err, &ue): slog.ErrorContext(ctx, "llm upstream error", "provider", ue.Provider, "status", ue.StatusCode, "body", ue.Body, ) case errors.Is(err, context.DeadlineExceeded): slog.WarnContext(ctx, "llm request exceeded deadline") case errors.Is(err, context.Canceled): // expected; suppress case errors.Is(err, llmrouter.ErrInvalidConfig): slog.ErrorContext(ctx, "llm config error", "err", err) default: slog.ErrorContext(ctx, "llm unknown error", "err", err) }}Full example: tiered error handler
Putting it together — a function that retries 429 and 5xx, fails fast on other 4xx, surfaces stream errors to the caller, and distinguishes cancellation from deadline-exceeded:
package main
import ( "context" "errors" "fmt" "io" "log/slog" "strings" "time"
"github.com/elloloop/llmrouter")
// completeWithRetry calls p.CompletionStream with retry on transient// failures, then streams the response into a string. The retry only// applies to the open phase; stream-read errors are surfaced as-is.func completeWithRetry( ctx context.Context, p llmrouter.Provider, req llmrouter.ChatRequest, maxAttempts int,) (string, error) { var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { text, err := completeOnce(ctx, p, req) if err == nil { return text, nil } if !isTransient(err) { return "", err } lastErr = err slog.WarnContext(ctx, "llm transient failure", "provider", p.Name(), "attempt", attempt, "err", err.Error(), ) select { case <-time.After(backoff(attempt)): case <-ctx.Done(): return "", ctx.Err() } } return "", fmt.Errorf("after %d attempts: %w", maxAttempts, lastErr)}
func completeOnce(ctx context.Context, p llmrouter.Provider, req llmrouter.ChatRequest) (string, error) { stream, err := p.CompletionStream(ctx, req) if err != nil { return "", err } var out strings.Builder for chunk := range stream.Chunks() { for _, c := range chunk.Choices { out.WriteString(c.Delta.Content) } } if err := stream.Err(); err != nil { // Stream errors are NOT retried — partial content was already // observed by the producer goroutine. Surface and let the // caller decide. return out.String(), err } return out.String(), nil}
// isTransient returns true for errors that may resolve on retry.func isTransient(err error) bool { // context cancellation / deadline are not retryable here — the // caller's deadline applies to every attempt. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } var ue *llmrouter.ErrUpstream if errors.As(err, &ue) { switch ue.StatusCode { case 408, 429, 500, 502, 503, 504: return true default: return false } } // Bare network errors at the open phase: retry. return errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "EOF")}
func backoff(attempt int) time.Duration { return time.Duration(1<<attempt) * 500 * time.Millisecond}Next steps
- Context & Cancellation — how deadlines and cancellation interact with streams.
- Configuration
& Options — wiring a custom
http.Clientwith retries throughWithHTTPClient. - The Streaming
Model — for the broader picture of stream lifecycle and
Err()semantics.