LLM API Integration Patterns for Backend Engineers
Lesson 3 of 8
Key Takeaways
- →Building a provider abstraction from day one lets you swap models and layer retry/fallback/cost tracking without touching business logic
- →Streaming responses directly to clients with `X-Accel-Buffering: no` prevents reverse-proxy buffering from turning incremental delivery into buffered waits
- →Pre-count tokens before API calls to catch context-window overflows client-side instead of as a 400 from the provider
- →Exponential backoff respecting `Retry-After` headers beats fixed-interval retries for handling provider rate limits and transient failures
- →A cost circuit breaker on monthly spend stops one errant loop from burning the month's budget — trip the kill switch below 100% (e.g., 90%), since in-flight requests still bill
A tutorial LLM API call is a few lines that production will break in at least six ways.
The easy part is the API call. The hard part is retrying on rate limits, streaming responses without buffering, function calling with validation, token budgets to avoid truncation, cost tracking to prevent runaway spend (OWASP's LLM10:2025 Unbounded Consumption[1]), and observability to diagnose failures.
Build a provider abstraction from day one, wrap it with retry and fallback logic, count tokens before every call, stream responses instead of buffering, track costs against a monthly budget, and instrument with structured logs and metrics. Real production code in Go — all patterns apply equally to Anthropic, OpenAI, and any LLM provider.
- Provider interface: Single seam for swapping models and implementing cross-cutting concerns (retry, fallback, cost tracking, tracing)
- Streaming + SSE: Deliver responses incrementally; flush to clients immediately with
X-Accel-Buffering: noto prevent reverse-proxy buffering - Token budgets: Pre-count tokens before calling the API; catch context-window overflows client-side, not as a 400
- Cost circuit breaker: Record spend per model via Prometheus[2]; trip a kill switch short of the monthly budget (e.g., 90%)
- Retry + fallback: Exponential backoff respecting Retry-After headers; fall back to cheaper models when primary is rate-limited or slow
graph TD
BL[Business logic:<br/>tools, prompts, orchestration] --> Obs[Observable wrapper:<br/>traces, metrics, cost]
Obs --> Fb[Fallback chain:<br/>GPT-4o → Sonnet → mini]
Fb --> Rt[Retry wrapper:<br/>exp backoff, Retry-After]
Rt --> P[Provider interface:<br/>Complete + Stream]
P --> Imp1[OpenAI impl]
P --> Imp2[Anthropic impl]
P --> Imp3[Ollama impl]
Cost[Cost circuit breaker<br/>$/month kill switch] -.->|cuts off| Obs
Tok[Token pre-counter] -.->|gates| BL
style BL fill:#eef
style Cost fill:#fee
style Tok fill:#fee
The diagram is the layered architecture in one picture: business logic talks to a single Provider interface, with observable / fallback / retry wrappers stacked between. Cost circuit-breaker and token pre-counter are the kill switches that protect the wallet from runaway loops — they're not part of the call path; they cut into it from outside.
The Quick Start: Integration Pattern Architecture
Every production LLM integration needs five layers. Build them in order; each is independently useful.
| Layer | Purpose | Example Pattern |
|---|---|---|
| Base provider | HTTP client, connection pooling, provider-specific marshaling | OpenAI, Anthropic, Ollama |
| Retry wrapper | Exponential backoff with jitter; respects Retry-After headers | Rate-limit 429, 500–504, 529, timeouts |
| Fallback chain | Try primary model, then cheaper alternatives on failure | GPT-4o → Claude Sonnet → GPT-4o-mini |
| Observable wrapper | Traces, metrics, cost tracking, structured logs | OTel spans, Prometheus counters, JSON logs |
| Business logic | Tool calling, prompt templates, streaming orchestration | Handler, Orchestrator, tool registry |
Build the base provider and retry logic first. Add streaming when latency matters. Layer cost tracking before production. Build prompts as a registry as volume grows.
Provider Abstraction
The first mistake teams make is scattering openai.NewClient() calls across their codebase. When you need to add retry logic, switch to Claude for a rate-limited outage, or track costs, you're editing dozens of files.
Start with a single interface that abstracts provider differences. This seam becomes the place where you compose retry, fallback, observability, and cost tracking without touching business logic:
package llm
import (
"context"
"io"
)
type Provider interface {
Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error)
Stream(ctx context.Context, req *CompletionRequest) (StreamReader, error)
}
type StreamReader interface {
Next() (StreamChunk, error)
Close() error
}
type CompletionRequest struct {
// JSON tags are required: without them json.Marshal emits "Model"/"MaxTokens"
// and the API 400s. These field names (model, messages, max_tokens, stream …)
// are shared by OpenAI and Anthropic, so the type stays provider-neutral;
// provider-specific quirks belong in each provider's request mapping.
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []ToolDefinition `json:"tools,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type CompletionResponse struct {
Content string
ToolCalls []ToolCall
Usage TokenUsage
Model string
FinishReason string
}
type TokenUsage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
}Concrete OpenAI implementation with HTTP/2 pooling:
type OpenAIProvider struct {
apiKey string
baseURL string
httpClient *http.Client
}
func NewOpenAIProvider(apiKey string) *OpenAIProvider {
return &OpenAIProvider{
apiKey: apiKey,
baseURL: "https://api.openai.com/v1",
httpClient: &http.Client{
Timeout: 120 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true, // keeps HTTP/2 if a custom dialer/TLS config is added
},
},
}
}
func (p *OpenAIProvider) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("execute: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp)
}
var result openAIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return result.toCompletionResponse(), nil
}ForceAttemptHTTP2: true matters once the transport gets a custom DialContext or TLSClientConfig: Go then conservatively disables HTTP/2[3], so concurrent requests each hold their own HTTP/1.1 connection and pay their own TLS handshake instead of multiplexing over one. Setting it up front keeps HTTP/2 when someone adds a custom dialer later.
Streaming via SSE
Non-streaming completions buffer the entire response in memory, making users wait 10–30 seconds watching a spinner. Streaming via Server-Sent Events (SSE) is the difference between "thinking…" and watching tokens arrive in real time.
Implement a StreamReader that yields chunks as they arrive, then proxy each chunk directly to the client with an immediate flush. This pattern works the same way for OpenAI, Anthropic, or any provider that supports streaming:
func (p *OpenAIProvider) Stream(ctx context.Context, req *CompletionRequest) (StreamReader, error) {
req.Stream = true
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := p.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("execute: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, parseAPIError(resp)
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
return &openAIStreamReader{scanner: scanner, resp: resp}, nil
}
func (r *openAIStreamReader) Next() (StreamChunk, error) {
for r.scanner.Scan() {
line := r.scanner.Text()
if line == "" || !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return StreamChunk{}, io.EOF
}
var chunk openAIStreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return StreamChunk{}, fmt.Errorf("decode stream chunk: %w", err)
}
return chunk.toStreamChunk(), nil
}
// Scan also stops on read errors (connection reset, a line over the 1 MB
// buffer). Either that or a body that ends before [DONE] is a truncated
// answer, never a clean io.EOF.
if err := r.scanner.Err(); err != nil {
return StreamChunk{}, fmt.Errorf("read stream: %w", err)
}
return StreamChunk{}, io.ErrUnexpectedEOF
}Proxy to HTTP clients with immediate flush:
func (h *ChatHandler) StreamCompletion(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering
stream, err := h.provider.Stream(r.Context(), parseRequest(r))
if err != nil {
http.Error(w, "upstream failed", http.StatusBadGateway)
return
}
defer stream.Close()
send := func(payload []byte) error {
if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil {
return err
}
flusher.Flush()
return nil
}
for {
chunk, err := stream.Next()
if errors.Is(err, io.EOF) {
if err := send([]byte("[DONE]")); err != nil {
h.logger.Info("client disconnected", "err", err)
}
return
}
if err != nil {
// No [DONE]: its absence tells the client the answer is truncated.
h.logger.Warn("upstream stream failed", "err", err)
return
}
data, err := json.Marshal(chunk)
if err != nil {
h.logger.Error("marshal chunk", "err", err)
return
}
if err := send(data); err != nil {
h.logger.Info("client disconnected", "err", err)
return
}
}
}The X-Accel-Buffering: no header is critical—without it, nginx buffers the entire response before sending it to the client, silently breaking the streaming experience without any error.
Retry with Exponential Backoff
The retry decision tree — route by error type, never blindly:
graph TD
Err[LLM API error] --> Type{HTTP status?}
Type -->|429 Rate limit| Header{Retry-After<br/>header set?}
Header -->|Yes| Wait[Sleep header value<br/>then retry]
Header -->|No| Backoff[Exponential backoff<br/>+ full jitter<br/>cap at max_delay]
Type -->|500, 502, 503, 504, 529| Backoff
Type -->|Network timeout<br/>or connection reset| Backoff
Type -->|400 with content_filter| NoRetry[NEVER retry<br/>same prompt fails again<br/>log + return error]
Type -->|400 invalid_request| NoRetry
Type -->|401 / 403 auth| NoRetry
Type -->|429 quota or spend cap| NoRetry
Backoff --> Counter{Attempts<br/>under max?}
Counter -->|Yes| Try[Retry call]
Counter -->|No — exhausted| Final[Return final error<br/>open circuit breaker<br/>fall back to cache or<br/>graceful degraded response]
Wait --> Counter
Try --> Type
style NoRetry fill:#fdd
style Final fill:#fdd
style Wait fill:#dfd
style Backoff fill:#ffd
LLM APIs fail in well-understood ways: 429 (rate limit), 500–504 and Anthropic's 529 (transient server errors[4]), network timeouts, and occasional malformed responses. Distinguish retryable failures from permanent ones rather than retrying everything.
Rate limits (429) may send a Retry-After header[5] with the wait time. But invalid requests (400), auth failures (401/403), content filter rejections (400 with specific error message), and quota or spend-limit 429s, which keep failing until access resumes[5], should not be retried.
Wrap the provider with retry logic that respects these distinctions:
type RetryProvider struct {
inner Provider
config RetryConfig
logger *slog.Logger
}
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
}
func (r *RetryProvider) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
if attempt > 0 {
backoff := r.calculateBackoff(attempt, lastErr)
r.logger.Info("retrying", "attempt", attempt, "backoff", backoff)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
resp, err := r.inner.Complete(ctx, req)
if err == nil {
return resp, nil
}
lastErr = err
if !isRetryable(err) {
return nil, err // fail immediately on non-retryable errors
}
}
return nil, fmt.Errorf("exhausted %d retries: %w", r.config.MaxRetries, lastErr)
}
func (r *RetryProvider) calculateBackoff(attempt int, err error) time.Duration {
// Respect Retry-After header
if apiErr, ok := err.(*APIError); ok && apiErr.RetryAfter > 0 {
return apiErr.RetryAfter
}
// Exponential backoff with full jitter
base := float64(r.config.InitialBackoff) * math.Pow(2, float64(attempt-1))
if base > float64(r.config.MaxBackoff) {
base = float64(r.config.MaxBackoff)
}
return time.Duration(rand.Float64() * base)
}
func isRetryable(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case 429:
// Quota/spend 429s (OpenAI billing error.code values, Anthropic's
// spend-cap 429 without retry-after) keep failing until access
// resumes; parseAPIError sets QuotaExhausted for them.
return !apiErr.QuotaExhausted
case 500, 502, 503, 504, 529: // 529: Anthropic overloaded_error
return true
}
return false
}
return !errors.Is(err, context.Canceled)
}When the primary model exhausts retries (or is consistently slow), fall back to a cheaper alternative. Don't fail the request—degrade gracefully:
type FallbackProvider struct {
chain []ProviderWithModel
}
type ProviderWithModel struct {
Provider Provider
Model string
}
func (f *FallbackProvider) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
var lastErr error
for _, pm := range f.chain {
reqCopy := *req
reqCopy.Model = pm.Model
resp, err := pm.Provider.Complete(ctx, &reqCopy)
if err == nil {
return resp, nil
}
lastErr = err
}
return nil, fmt.Errorf("all providers failed: %w", lastErr)
}Typical fallback chain: GPT-4o (best) → Claude Sonnet (if OpenAI is rate-limited) → GPT-4o-mini (cheapest, last resort). Each provider is wrapped with its own retry logic; the fallback chain lets you survive outages by switching models transparently. Chain ordering, circuit breakers that catch failures arriving as HTTP 200, and what the product serves when every target is down are the subject of the outage playbook.
Token Budgets
LLM context windows are finite. GPT-4o has 128K[6]; Claude Haiku 4.5 has 200K and Claude Sonnet 5 has 1M[7]. But add RAG context, conversation history, tool definitions, and system prompts—the tokens add up fast. Input over the limit gets a 400[8]; input near it can truncate the output[9].
Count tokens before the API call so context-window overflows fail client-side:
import "github.com/pkoukk/tiktoken-go"
// tiktoken ships OpenAI encodings only; for Claude, use Anthropic's
// token-counting endpoint instead.
type TokenCounter struct {
mu sync.Mutex
encodings map[string]*tiktoken.Tiktoken // building an encoder is the slow part
}
func (tc *TokenCounter) encoder(model string) (*tiktoken.Tiktoken, error) {
tc.mu.Lock()
defer tc.mu.Unlock()
if enc, ok := tc.encodings[model]; ok {
return enc, nil
}
enc, err := tiktoken.EncodingForModel(model)
if err != nil {
return nil, fmt.Errorf("tokenizer for %s: %w", model, err)
}
if tc.encodings == nil {
tc.encodings = make(map[string]*tiktoken.Tiktoken)
}
tc.encodings[model] = enc
return enc, nil
}
func (tc *TokenCounter) CountMessages(model string, messages []Message) (int, error) {
// gpt-4o-family overheads from OpenAI's "How to count tokens with
// tiktoken" cookbook: 3 per message, 3 to prime the reply.
const perMessage = 3
const replyPriming = 3
enc, err := tc.encoder(model)
if err != nil {
return 0, err
}
total := replyPriming
for _, msg := range messages {
total += perMessage + len(enc.Encode(msg.Role, nil, nil)) + len(enc.Encode(msg.Content, nil, nil))
}
return total, nil
}When conversation exceeds budget, use a sliding window strategy: keep the system prompt (always), drop oldest user/assistant turns first, and preserve recent context:
func SlidingWindow(messages []Message, maxTokens int, counter *TokenCounter, model string) ([]Message, error) {
var system, conversation []Message
for _, m := range messages {
if m.Role == "system" {
system = append(system, m)
} else {
conversation = append(conversation, m)
}
}
result := append([]Message(nil), system...)
budget := maxTokens
systemTokens, err := counter.CountMessages(model, system)
if err != nil {
return nil, fmt.Errorf("count system prompt: %w", err)
}
budget -= systemTokens
// Walk newest-to-oldest, keeping each turn that still fits the budget.
// Prepend kept turns into `kept` so chronological order is preserved;
// never mutate `conversation` while iterating it.
var kept []Message
for i := len(conversation) - 1; i >= 0 && budget > 0; i-- {
msgTokens, err := counter.CountMessages(model, []Message{conversation[i]})
if err != nil {
return nil, fmt.Errorf("count turn %d: %w", i, err)
}
if msgTokens > budget {
break
}
kept = append([]Message{conversation[i]}, kept...)
budget -= msgTokens
}
return append(result, kept...), nil
}Simple and predictable—works well for chat where recent context matters most. The downside: important information from early in the conversation is lost. For tool-calling workflows, you can also use prioritization (system prompt + tool results are critical; older assistant messages are lowest priority).
Cost Tracking
Without cost tracking, a runaway loop (model endlessly retrying, RAG context too large, long conversation histories) can drain the budget unnoticed. Record token usage per model and trip a kill switch short of the monthly budget (here, 90%): requests already in flight still bill after it fires. The prices below are list prices on 2026-09-25[6][10][7]; load them from config, because they change.
Before you write the tracking code, see the shape of the bill it's tracking: the LLM Conversation Cost Calculator shows what turn N — and the whole conversation — costs as history keeps getting resent, with and without prompt caching.
var (
tokenUsage = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "llm_tokens_total",
}, []string{"model", "type"})
requestCost = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "llm_cost_dollars_total",
}, []string{"model"})
)
// USD per 1M input/output tokens. Keys must be the exact model IDs you send.
var pricing = map[string]struct{ Input, Output float64 }{
"gpt-4o": {2.50, 10.00},
"gpt-4o-mini": {0.15, 0.60},
"claude-sonnet-5": {2.00, 10.00},
"claude-haiku-4-5": {1.00, 5.00},
}
// killAt trips the switch before 100%: in-flight requests still bill.
const killAt = 0.90
type CostTracker struct {
monthlyBudget float64
currentSpend float64
periodMonth time.Month // resets currentSpend when the calendar month rolls
mu sync.Mutex
killSwitch func()
}
func (ct *CostTracker) Record(model string, usage TokenUsage) error {
price, ok := pricing[model]
if !ok {
// An unpriced model must not count as $0—that silently disables the kill switch.
return fmt.Errorf("no price for model %q", model)
}
cost := (float64(usage.PromptTokens)/1_000_000)*price.Input +
(float64(usage.CompletionTokens)/1_000_000)*price.Output
tokenUsage.WithLabelValues(model, "prompt").Add(float64(usage.PromptTokens))
tokenUsage.WithLabelValues(model, "completion").Add(float64(usage.CompletionTokens))
requestCost.WithLabelValues(model).Add(cost)
ct.mu.Lock()
defer ct.mu.Unlock()
// Roll the window each calendar month so "monthly budget" is real — without
// this, currentSpend only ever grows and the kill switch stays tripped after
// month one. This counter is per-replica; the authoritative cross-fleet
// monthly figure is sum(requestCost) in Prometheus, so drive fleet-wide
// enforcement off that (or a shared store) rather than this local total.
if m := time.Now().UTC().Month(); m != ct.periodMonth {
ct.periodMonth = m
ct.currentSpend = 0
}
ct.currentSpend += cost
if ct.currentSpend >= killAt*ct.monthlyBudget && ct.killSwitch != nil {
slog.Error("LLM budget kill switch tripped", "spend", ct.currentSpend, "budget", ct.monthlyBudget)
ct.killSwitch()
}
return nil
}Function Calling
Modern LLM APIs let models request tool execution instead of just generating text. The model says "I need to look up an order" and you execute order_lookup, then feed the result back to the model so it can generate a final response.
Always validate tool arguments against a schema before execution. The model generates structured JSON, but it can hallucinate invalid order IDs, SQL injection attempts, or out-of-range values. Never trust the model's JSON—parse it, validate it, execute it safely.
Define a registry mapping tool names to handlers and schemas. Compile each tool's JSON Schema once at registration—Execute validates against the compiled schema on every call, using github.com/santhosh-tekuri/jsonschema/v6:
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"` // JSON Schema
}
type ToolCall struct {
ID string
Name string
Arguments string
}
type Registry struct {
tools map[string]ToolDefinition
schemas map[string]*jsonschema.Schema
handlers map[string]func(context.Context, json.RawMessage) (string, error)
}
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]ToolDefinition),
schemas: make(map[string]*jsonschema.Schema),
handlers: make(map[string]func(context.Context, json.RawMessage) (string, error)),
}
}
func (r *Registry) Register(def ToolDefinition, handler func(context.Context, json.RawMessage) (string, error)) error {
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(def.Parameters))
if err != nil {
return fmt.Errorf("parse schema for %s: %w", def.Name, err)
}
// A bare name would resolve to file://<cwd>/<name> and leak server paths
// into the validation errors the model sees; use a scheme-qualified URL.
schemaURL := "mem://tools/" + def.Name
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource(schemaURL, doc); err != nil {
return fmt.Errorf("add schema resource for %s: %w", def.Name, err)
}
schema, err := compiler.Compile(schemaURL)
if err != nil {
return fmt.Errorf("compile schema for %s: %w", def.Name, err)
}
r.tools[def.Name] = def
r.schemas[def.Name] = schema
r.handlers[def.Name] = handler
return nil
}
func (r *Registry) Execute(ctx context.Context, call ToolCall) string {
handler, ok := r.handlers[call.Name]
if !ok {
return "unknown tool"
}
// Parsing only proves call.Arguments is syntactically valid JSON—it says
// nothing about whether an order ID is the right type or a value is in
// range. Extra fields pass too unless the schema sets
// "additionalProperties": false (JSON Schema allows them by default).
// Validate against the compiled schema before the handler sees anything.
doc, err := jsonschema.UnmarshalJSON(strings.NewReader(call.Arguments))
if err != nil {
return "invalid arguments: malformed JSON"
}
if err := r.schemas[call.Name].Validate(doc); err != nil {
return "invalid arguments: " + err.Error()
}
// 30-second timeout on tool execution
toolCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := handler(toolCtx, json.RawMessage(call.Arguments))
if err != nil {
return "execution failed: " + err.Error()
}
return result
}Production Checklist
- Provider abstraction: All calls via a single interface, not scattered
openai.NewClient()calls - HTTP/2 kept on:
ForceAttemptHTTP2: trueon any transport with a custom dialer or TLS config - Retries with backoff: Exponential backoff respecting
Retry-Afterheaders; max 3 retries - Fallback chain: Primary model + 2–3 cheaper alternatives
- Token counting: Pre-count before every call; catch context-window overflows client-side
- Cost tracking: Prometheus metrics per model; monthly budget with a kill switch short of it (e.g., 90%)
- Streaming with flush: SSE chunks flushed immediately;
X-Accel-Buffering: noon proxies - Tool validation: Validate tool arguments against a schema before execution
- Observability: OTel spans capturing model, tokens, duration, cost; structured logs with sampling
- Timeout safety: 120s on HTTP client, 30s on tool execution
- Prompt templates: Store as versioned templates, not hardcoded strings; snapshot-test golden files
Streaming with Backpressure
The naive SSE handler shown earlier is correct for fast clients. It breaks the moment a client is slow — a mobile network on a train, a tab in a backgrounded browser, a curl reader piping through less. The provider stream arrives at hundreds of tokens per second; the client drains at twenty. Once the client's TCP send buffer fills, the naive handler's write blocks, and with no write deadline a single stuck client pins the handler goroutine and its scanner buffer for as long as the socket stays wedged, and holds the upstream provider connection open until the 120-second client timeout kills it. Multiply by a few thousand connections and the pod gets OOM-killed.
The fix is to honour the slowest party in the chain. Wrap the writer in a context-aware bounded channel, drop the request if the client cannot keep up, and always release the upstream provider connection on disconnect. Three rules, in order: detect the disconnect early, bound the buffer, and abort the upstream read when the client goes away.
type backpressuredWriter struct {
w http.ResponseWriter
deadline time.Duration
}
func (bw *backpressuredWriter) writeChunk(ctx context.Context, chunk StreamChunk) error {
data, err := json.Marshal(chunk)
if err != nil {
return fmt.Errorf("marshal chunk: %w", err)
}
// Per-write deadline detects slow clients without blocking forever.
// SetWriteDeadline is exposed via the http.ResponseController in Go 1.20+.
rc := http.NewResponseController(bw.w)
if err := rc.SetWriteDeadline(time.Now().Add(bw.deadline)); err != nil {
return fmt.Errorf("set deadline: %w", err)
}
if _, err := fmt.Fprintf(bw.w, "data: %s\n\n", data); err != nil {
return fmt.Errorf("write: %w", err) // client disconnected or stalled
}
// Small chunks sit in the response buffer; the socket write (and the
// deadline error) happens here. http.Flusher.Flush would drop that error.
if err := rc.Flush(); err != nil {
return fmt.Errorf("flush: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}A 10-second per-chunk deadline catches a wedged TCP socket without making the happy path slower. When the deadline fires, the flush returns an error, the handler unwinds, and the deferred stream.Close() aborts the upstream HTTP request to OpenAI or Anthropic — releasing the file descriptor and the API quota immediately rather than letting it idle until the 120-second client timeout.
The handler that drives this writer needs to run the read and write on separate goroutines so that an upstream pause cannot starve the disconnect detector. Use a bounded channel as the queue between them — when it fills, the producer goroutine blocks, and the provider's TCP receive window naturally throttles. That pushes backpressure all the way to the LLM provider's edge, which is exactly where you want it.
func (h *ChatHandler) StreamWithBackpressure(w http.ResponseWriter, r *http.Request) {
_, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
stream, err := h.provider.Stream(ctx, parseRequest(r))
if err != nil {
http.Error(w, "upstream failed", http.StatusBadGateway)
return
}
defer stream.Close()
bw := &backpressuredWriter{w: w, deadline: 10 * time.Second}
queue := make(chan StreamChunk, 16) // bounded; fills if client is slow
var upstreamErr error // set before close(queue), read after the range ends
go func() {
defer close(queue)
for {
chunk, err := stream.Next()
if errors.Is(err, io.EOF) {
return
}
if err != nil {
upstreamErr = err
return
}
select {
case queue <- chunk:
case <-ctx.Done():
return // client disconnected — stop reading from provider
}
}
}()
for chunk := range queue {
if err := bw.writeChunk(ctx, chunk); err != nil {
h.logger.Info("client disconnected", "err", err)
cancel() // unblock the producer goroutine
return
}
}
if upstreamErr != nil {
// No [DONE]: its absence tells the client the answer is truncated.
h.logger.Warn("upstream read failed", "err", upstreamErr)
return
}
if _, err := fmt.Fprintf(w, "data: [DONE]\n\n"); err != nil {
h.logger.Info("client disconnected", "err", err)
return
}
// Flusher.Flush would drop a stalled write's error here too — see writeChunk.
if err := http.NewResponseController(w).Flush(); err != nil {
h.logger.Info("client disconnected", "err", err)
}
}The buffer size of 16 chunks is deliberate. Smaller and a brief client pause stalls the producer; larger and the heap grows with every stuck connection. At sixteen chunks of roughly 200 bytes, a thousand parallel slow clients cost about 3 MB of buffered data — bounded and predictable. Track the queue depth as a Prometheus histogram and alert when the p99 sits at capacity, which means clients are systematically slower than the provider and you should consider an admission gate or a reduced concurrency limit. The same pattern applies to Anthropic's Messages stream and any provider that returns SSE.
Frequently Asked Questions
How do you handle LLM API rate limits in production?
Implement exponential backoff with jitter on rate-limit 429s (not quota or spend-limit 429s), use a circuit breaker to fall back to a cheaper model when the primary provider is rate-limited or degraded, and track token usage per request to stay within provider quotas. Pre-count tokens before sending so context-window overflows fail client-side, not as API errors.
How do you stream LLM responses without buffering the full completion?
Use the provider's streaming API (SSE) and implement a StreamReader interface that delivers tokens incrementally. Pipe the stream directly through your HTTP response using chunked transfer encoding so neither your server nor the client waits for the full completion to finish.
How do you manage LLM API costs in production?
Track token usage per request with Prometheus metrics, set per-user and per-tenant token budgets, use cheaper models for simple tasks and reserve expensive models for complex ones, and implement a cost circuit breaker that alerts or throttles when spend exceeds thresholds.
Should you use one LLM provider or abstract across multiple?
Abstract behind a common Provider interface from day one. This lets you swap models without changing business logic, implement automatic fallback from an expensive model to a cheaper one during outages, and A/B test providers. The interface overhead is minimal compared to the operational flexibility.
Keep Reading
- Your LLM Provider Will Have an Outage — The outage playbook this article's retry layer hands off to: LLM-aware circuit breakers, same-model-first fallback chains across clouds, and degraded modes
- Building Production RAG Pipelines — Chunking, embeddings, and retrieval strategies for feeding context into the LLM calls this article orchestrates
- Spring AI in Production — The Java/Spring equivalent: RAG pipelines, circuit breakers, and observability for LLM-powered backends
- The 3 Pillars of Observability — Prometheus metrics, structured logging, and distributed tracing for the LLM observability layer
Sources
- 1.OWASP Top 10 for LLM Applications 2025 — OWASP, 2025
- 2.Prometheus — Histograms, Summaries, and Labels Best Practices — Prometheus Project, 2026
- 3.net/http — Package http (Go standard library) — go.dev, 2026
- 4.Claude API — Errors — Anthropic, 2026
- 5.OpenAI API — Error codes guide — OpenAI, 2026
- 6.GPT-4o — Model — OpenAI, 2026
- 7.Claude API — Models overview — Anthropic, 2026
- 8.Claude API Docs — Context windows — Anthropic, 2026
- 9.OpenAI API Docs — Conversation state — OpenAI, 2026
- 10.GPT-4o mini — Model — OpenAI, 2026
Engineering Team
An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.
Read Next
Designing a Multi-Agent Backend: The Orchestrator Pattern
Picture one agent, one context window, one serial loop — until it hits 180K tokens and silently drops 37 of the 40 services it was asked to audit. The orchestrator pattern fans work out to isolated sub-agents in parallel, then synthesizes. Here's the backend, in compiling Go.
Building an MCP Server in Go with Code Mode: From 1.17M Tokens to 1,000
Cloudflare put its 2,500+ API endpoints behind one MCP server with two tools, search + execute, and cut input tokens by 99.9%. The pattern in Go.
Your LLM Provider Will Have an Outage: Circuit Breakers, Fallback Chains, and Degraded Modes
OpenAI and Anthropic both document outages, overload codes, and best-effort availability. The survival kit: LLM-aware circuit breakers, same-model-first fallback chains, and degraded modes designed before you need them.