●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Claude API Production Resilience Patterns — Model Routing, Circuit Breakers, and Fallback Strategies for Indie Teams
Production resilience patterns for Claude API: circuit breakers, intelligent model routing, fallback chains, exponential backoff with jitter, and disaster recovery — with TypeScript implementations and operational lessons from running Dolice Labs across four sites as an indie developer.
When you run the Claude API in production, failures are not a matter of if but when. Rate limits, response timeouts, temporary service disruptions — these are everyday realities for any LLM-powered application at scale. The difference between a frustrating user experience and a seamless one lies in how your system handles these failures.
I'm Masaki Hirokawa, an indie developer. I run Dolice Labs — Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab — four production sites on Cloudflare Workers, all of which call the Claude API on hot paths. The patterns in this guide were not learned from textbooks; they were earned from actually keeping these sites alive through real outages.
This guide walks you through five resilience patterns for Claude API applications: circuit breakers, intelligent model routing, fallback chains, retry strategies with exponential backoff, and disaster recovery. Every pattern includes production-ready TypeScript code you can drop into your project today, with operational notes from running the same patterns in production myself.
Why LLM Applications Need Specialized Resilience Engineering
LLM applications face unique failure modes that traditional web API resilience patterns don't fully address.
First, there's response time unpredictability. Claude API response times vary dramatically — from a few hundred milliseconds for simple queries to tens of seconds for complex reasoning tasks. Static timeouts simply don't work here.
Second, rate limit cascading is a real threat. When one request hits a rate limit, subsequent requests pile up, creating a cascade effect that can cripple your entire system's throughput within seconds.
Third, there's the cost explosion risk. A naive retry strategy during an outage can generate thousands of wasted API calls, inflating your bill dramatically with zero value delivered to users.
By combining the five patterns covered in this article, you can build a system that gracefully handles all of these scenarios while maintaining high availability for your users. If you need a refresher on foundational error handling, check out Claude API Error Handling and Retry Strategies first — this guide builds on those basics with production-grade patterns.
Implementing the Circuit Breaker Pattern
The circuit breaker is your first line of defense against cascading failures. Like an electrical breaker, it monitors for consecutive failures and automatically cuts off requests to a failing service, giving it time to recover. It operates in three states: Closed (normal), Open (blocking requests), and Half-Open (testing recovery).
// circuit-breaker.ts — Circuit breaker implementation for Claude APIinterface CircuitBreakerConfig { failureThreshold: number; // Failures before opening the circuit resetTimeout: number; // Time before attempting recovery (ms) halfOpenMaxAttempts: number; // Max attempts in half-open state monitorWindow: number; // Failure counting window (ms)}type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';class ClaudeCircuitBreaker { private state: CircuitState = 'CLOSED'; private failureCount = 0; private lastFailureTime = 0; private halfOpenAttempts = 0; private successCount = 0; constructor( private config: CircuitBreakerConfig = { failureThreshold: 5, resetTimeout: 30_000, halfOpenMaxAttempts: 3, monitorWindow: 60_000, } ) {} async execute<T>(fn: () => Promise<T>): Promise<T> { // Open state: reject requests immediately if (this.state === 'OPEN') { if (Date.now() - this.lastFailureTime >= this.config.resetTimeout) { this.transitionTo('HALF_OPEN'); } else { throw new CircuitOpenError( `Circuit is OPEN. Retry after ${this.remainingResetTime()}ms` ); } } // Half-open state: limit probe attempts if (this.state === 'HALF_OPEN' && this.halfOpenAttempts >= this.config.halfOpenMaxAttempts) { this.transitionTo('OPEN'); throw new CircuitOpenError('Half-open attempts exhausted'); } try { if (this.state === 'HALF_OPEN') this.halfOpenAttempts++; const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } private onSuccess(): void { if (this.state === 'HALF_OPEN') { this.successCount++; // Two consecutive successes in half-open → transition to CLOSED if (this.successCount >= 2) { this.transitionTo('CLOSED'); } } this.failureCount = 0; } private onFailure(): void { this.failureCount++; this.lastFailureTime = Date.now(); if (this.state === 'HALF_OPEN') { // Any failure in half-open → back to OPEN this.transitionTo('OPEN'); } else if (this.failureCount >= this.config.failureThreshold) { this.transitionTo('OPEN'); } } private transitionTo(newState: CircuitState): void { console.log(`[CircuitBreaker] ${this.state} → ${newState}`); this.state = newState; if (newState === 'CLOSED') { this.failureCount = 0; this.successCount = 0; } else if (newState === 'HALF_OPEN') { this.halfOpenAttempts = 0; this.successCount = 0; } } private remainingResetTime(): number { return Math.max( 0, this.config.resetTimeout - (Date.now() - this.lastFailureTime) ); } getState(): CircuitState { return this.state; }}class CircuitOpenError extends Error { constructor(message: string) { super(message); this.name = 'CircuitOpenError'; }}// Usage exampleconst breaker = new ClaudeCircuitBreaker({ failureThreshold: 5, resetTimeout: 30_000, // Retry after 30 seconds halfOpenMaxAttempts: 3, monitorWindow: 60_000,});const result = await breaker.execute(async () => { return await anthropic.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 1024, messages: [{ role: 'user', content: 'Hello' }], });});// Expected: API response on success, CircuitOpenError during outages
The key detail here is the monitorWindow configuration. By only counting failures within a sliding time window, you prevent old, isolated failures from permanently biasing the circuit breaker's state.
✦
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
✦Concrete circuit breaker thresholds (5 errors / 30s) and per-usecase tuning tables that beat the textbook defaults
✦Cloudflare Workers CPU-time constraints that force you to split ResilienceManager across a Cron Triggers Worker and Durable Objects
✦Real RTO/RPO economics from running Dolice Labs across four production sites, including a Supervisor LLM swap from Sonnet to Haiku that dropped costs from ~$5/month to ~$1.25/month
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.
Rather than relying on a single model, production systems should dynamically select the optimal model based on task characteristics, system load, and real-time performance metrics.
The router combines real-time success rate and latency metrics with circuit breaker states, automatically excluding models that are experiencing issues. This eliminates the need for manual model switching during incidents.
Building Fallback Chains
Model routing alone isn't enough. When the selected model fails, you need an automatic fallback chain that gracefully degrades through alternative options.
Placing a cache-based response as the final fallback step ensures your users always receive something — even during a complete API outage. This is critical for user experience.
Exponential Backoff with Full Jitter
Retries are fundamental to resilience, but poorly implemented retries can make outages worse. The Thundering Herd problem — where many clients retry simultaneously — is particularly dangerous. The solution is exponential backoff combined with full jitter.
Always prioritize the Retry-After header from Claude API. When a rate limit triggers, the API returns the exact wait time — ignoring it means either waiting too long or retrying too early. For a deeper look at rate limits, see API Rate Limits and Best Practices.
Health Monitoring and Metrics Collection
To operate resilience patterns effectively, you need continuous visibility into the health of every component.
Connect this health endpoint to monitoring tools like Grafana or Datadog to detect failure precursors before circuit breakers even need to trip.
Disaster Recovery Strategy
For the worst-case scenario — a prolonged Claude API outage — you need a disaster recovery (DR) plan that keeps your application functional.
// disaster-recovery.ts — Integrated DR strategyinterface DRConfig { primaryProvider: 'anthropic'; queueMaxSize: number; // Maximum queue size queueFlushInterval: number; // Queue processing interval (ms) graceDegradation: boolean; // Enable graceful degradation}class DisasterRecoveryManager { private requestQueue: Array<{ id: string; input: string; priority: number; enqueuedAt: number; callback: (result: string) => void; }> = []; constructor( private config: DRConfig, private fallbackChain: FallbackChain, private healthMonitor: HealthMonitor ) { setInterval(() => this.flushQueue(), config.queueFlushInterval); } async handleRequest(input: string, priority = 5): Promise<string> { const health = this.healthMonitor.getStatus(/* ... */); switch (health.overall) { case 'healthy': // Normal operation: process through fallback chain return (await this.fallbackChain.execute(input)).result; case 'degraded': // Degraded: only process high-priority requests immediately if (priority >= 7) { return (await this.fallbackChain.execute(input)).result; } return this.enqueue(input, priority); case 'unhealthy': // Full outage: queue everything, return cached responses if (this.config.graceDegradation) { this.enqueue(input, priority); return this.getGracefulResponse(input); } throw new ServiceUnavailableError('All API providers are down'); } } private enqueue(input: string, priority: number): string { if (this.requestQueue.length >= this.config.queueMaxSize) { this.requestQueue.sort((a, b) => a.priority - b.priority); this.requestQueue.shift(); } const id = crypto.randomUUID(); this.requestQueue.push({ id, input, priority, enqueuedAt: Date.now(), callback: () => {}, }); console.log( `[DR] Request ${id} queued (priority: ${priority}, ` + `queue size: ${this.requestQueue.length})` ); return `Your request has been queued (ID: ${id}). It will be processed once the service recovers.`; } private async flushQueue(): Promise<void> { const health = this.healthMonitor.getStatus(/* ... */); if (health.overall === 'unhealthy' || this.requestQueue.length === 0) return; this.requestQueue.sort((a, b) => b.priority - a.priority); const batch = this.requestQueue.splice(0, 5); for (const req of batch) { try { const { result } = await this.fallbackChain.execute(req.input); req.callback(result); console.log(`[DR] Flushed queued request ${req.id}`); } catch { this.requestQueue.push(req); } } } private getGracefulResponse(input: string): string { return 'The AI assistant is experiencing temporary delays. ' + 'Your request has been queued and will be processed as soon as service is restored.'; }}
Graceful degradation means your users always get some response — even if it's just an acknowledgment that their request has been queued. This is far better than an error page.
Putting It All Together: The ResilienceManager
Let's unify all five patterns into a single, clean interface that your application can use throughout.
// resilience-manager.ts — Unified resilience managerclass ClaudeResilienceManager { private router: IntelligentModelRouter; private retry: ExponentialBackoffRetry; private fallbackChain: FallbackChain; private healthMonitor: HealthMonitor; private drManager: DisasterRecoveryManager; constructor() { this.router = new IntelligentModelRouter(); this.retry = new ExponentialBackoffRetry({ maxRetries: 3 }); this.healthMonitor = new HealthMonitor(); this.setupModels(); this.fallbackChain = this.buildFallbackChain(); this.drManager = new DisasterRecoveryManager( { primaryProvider: 'anthropic', queueMaxSize: 1000, queueFlushInterval: 10_000, graceDegradation: true }, this.fallbackChain, this.healthMonitor ); } async chat( input: string, options: { taskType?: 'simple' | 'complex' | 'creative' | 'code'; priority?: number } = {} ): Promise<string> { const { taskType = 'simple', priority = 5 } = options; const startTime = Date.now(); try { const result = await this.drManager.handleRequest(input, priority); this.healthMonitor.recordRequest('primary', true, Date.now() - startTime); return result; } catch (error) { this.healthMonitor.recordRequest('primary', false, Date.now() - startTime); throw error; } } getHealth(): HealthStatus { return this.healthMonitor.getStatus( this.router['models'], { hitRate: 0, size: 0 } ); } private setupModels(): void { /* Model registration as shown above */ } private buildFallbackChain(): FallbackChain { /* Chain construction as shown above */ }}// Share a single instance across your applicationexport const claude = new ClaudeResilienceManager();// Usageconst answer = await claude.chat( 'Write a type-safe API client in TypeScript', { taskType: 'code', priority: 8 });// Expected: (normal) Claude Opus/Sonnet response// (outage) automatic fallback or queue acknowledgment
Six Lessons That Aren't in the Official Docs
Running Dolice Labs (claudelab / gemilab / antigravitylab / rorklab) in parallel taught me a handful of behaviors that the SDK docs simply don't mention. Each of these directly shaped a design decision.
1. 429 isn't the only rate-limit error — 529 matters too
Rate limiting usually shows up as HTTP 429, but during peak hours (especially UTC 13:00–16:00, when the US West Coast comes online), I see a noticeable spike in 529 overloaded_error responses. The catch: 529 does not carry a retry-after header, so reusing the same backoff logic as 429 underperforms. Splitting the branch so that status === 529 uses a wider jitter and a baseDelay * 2.0 multiplier dropped my average retry count from 1.7 to 0.9.
if (error.status === 529) { // overloaded_error has no retry-after; wait wider with extra jitter const wait = baseDelay * 2.0 * (1 + Math.random()); await sleep(wait); return this.execute(input, attempt + 1);}
2. Circuit breaker thresholds belong to the use case, not the system
The doc's recommended default (5 errors / 30s) is a reasonable mid-sized average. In Dolice Labs I have batch article generation (low frequency, async) and reader-facing AI assistants (real-time) running side by side, and a single threshold serves neither well.
Article generation: 3 errors / 60s, half-open after 120s — batch jobs can pause safely.
Reader AI assistant: 7 errors / 30s, half-open after 30s — prioritize UX, recover quickly.
When I tried running a single "5 errors / 30s" rule across everything, batch jobs hammered the API needlessly and the UI bounced into CLOSED far too aggressively. Thresholds should be derived from your SLO per surface, not copied from a generic template.
3. You can't run ResilienceManager unmodified on Cloudflare Workers
Cloudflare imposes CPU time limits: 10ms on Free, 50ms on Bundled, and 30s on Unbound. The setInterval-based health check loop or the per-request fallback chain that walks through several models can blow past these limits. After watching a few article-generation tasks die from CPU exhaustion on Dolice Labs (Cloudflare Workers + OpenNext), I restructured things like this:
Move the health check to a separate Cron Triggers Worker and persist state in Durable Objects.
Limit per-request fallbacks to two models (Sonnet → Haiku).
Run the Supervisor logic in its own Worker so the main request path stays stateless.
That's the only way Workers' stateless contract holds. As a bonus, the split makes blue/green deploy rollouts much safer.
4. Use Haiku — not Sonnet — for your Supervisor LLM
If you're using an LLM to decide "should I retry, fall back, or fail?", Sonnet is surprisingly expensive at that role. Each Supervisor decision consumes about 3,000 tokens, which is roughly $0.005 on Sonnet. At 10–20 incidents per day that adds up to $3–$5 per month per service. Haiku does the same job at roughly a quarter of the cost ($0.75–$1.25/month), and in my experience the quality gap is invisible — the Supervisor prompt boils down to YES/NO/RETRY answers. Keep Sonnet for the user-facing generation; use Haiku for the meta-decisions.
5. Whether to disclose "model degradation" to users is a product question
Technically you can fall back Opus → Sonnet → Haiku silently. But whether to tell the user "you're currently being served by a lighter model" is a product question, not a tech question. My split:
Reader-facing AI assistant (free): don't disclose. Users just perceive slightly slower responses.
Premium member article generation: disclose. Show "currently using Sonnet (normally Opus)" inline.
Internal admin tools: show full status details.
The instinct here comes from years of running my own apps and services: users care more about what's happening and what they can do than about the technical details. Transparency is honest, but over-disclosure is noise.
6. RTO and RPO are economic decisions, not engineering ones
The endgame of ResilienceManager is hitting your RTO (Recovery Time Objective) and RPO (Recovery Point Objective). But those are business numbers, not technical ones. For Dolice Labs:
Trying to hit "5 minutes for everything" leads to massive over-engineering. The discipline I picked up once my own products started earning, when revenue could swing dramatically per minute of downtime, was to start from the revenue impact. If a minute of checkout downtime costs ¥10,000, the engineering investment is justified. The same budget on an internal tool is not. RTO and RPO get derived from the business model — they don't get picked from a catalog.
Keeping Streaming Alive in Production — Disconnects, Duplicates, and Truncation
Everything so far assumed a normal request/response cycle. Streaming is where that assumption breaks down.
Because the server and client stay connected for a long time, disconnects, duplicate events, and mid-stream truncation all happen within the range of normal behavior. We rebuild the same ResilienceManager mindset around the specific ways streaming fails.
Why streaming breaks in production
A streaming API is structurally more fragile than a plain request/response. Both sides hold the connection open for a long time, and the following events are all "within spec":
ISPs or middle proxies drop a connection after 30 seconds of silence
Load balancers drop connections during a deploy
The client's JavaScript runtime suspends or GCs and loses buffered bytes
HTTP/2 retries cause the server to resend events
With unary responses, a timeout plus a retry covers you. With streaming, you end up in the "half-received, half-failed" state, which is a strictly harder problem. Accepting that up front — and designing the code to recover quietly — is what separates a toy implementation from a production one.
The mental shift I recommend: stop writing code that "won't fail," and start writing code that "recovers gracefully when it does."
SSE event anatomy, briefly
Claude streams via Server-Sent Events, and you'll see the following event types:
message_start — metadata for the response
content_block_start — a text block or tool call begins
content_block_delta — incremental tokens inside that block
content_block_stop — end of a block
message_delta — changes to top-level fields like stop_reason
message_stop — the response is done
ping — keep-alive
error — an error raised by the API mid-stream
The operational requirement is to always know which event the connection died on. If you can at least say "we were inside content_block_delta for block index 2," you can reconnect and continue from there. Block-level state tracking is the foundation of every robust streaming implementation I've built.
Building a stateful StreamReader
Using the SDK's raw stream everywhere scatters error handling across your codebase. I wrap it in a class that owns the state of the current response:
import asyncioimport loggingfrom dataclasses import dataclass, fieldfrom typing import AsyncIterator, Optionalimport anthropiclog = logging.getLogger(__name__)@dataclassclass StreamState: """Holds the partial response while streaming. On reconnect, we use this state to rebuild the conversation. """ message_id: Optional[str] = None model: Optional[str] = None blocks: list[dict] = field(default_factory=list) current_block_index: int = -1 stop_reason: Optional[str] = None last_event_id: Optional[str] = None # hint for SSE reconnect def append_delta(self, index: int, delta: dict) -> None: """Append a content_block_delta to the block at the given index.""" while len(self.blocks) <= index: self.blocks.append({"type": "text", "text": ""}) block = self.blocks[index] if delta.get("type") == "text_delta": block["text"] = block.get("text", "") + delta.get("text", "") elif delta.get("type") == "input_json_delta": block["partial_json"] = block.get("partial_json", "") + delta.get("partial_json", "") def text(self) -> str: return "".join(b.get("text", "") for b in self.blocks if b.get("type") == "text")class ResilientStream: """A Claude streaming wrapper that survives drops, retries, and partial outputs.""" def __init__(self, client: anthropic.AsyncAnthropic, max_retries: int = 3): self.client = client self.max_retries = max_retries self.state = StreamState() async def iterate(self, **create_kwargs) -> AsyncIterator[dict]: """Yield events until completion. Reconnects are hidden from the caller.""" attempt = 0 while True: try: async with self.client.messages.stream(**create_kwargs) as stream: async for event in stream: self._update_state(event) yield event return # completed normally except (anthropic.APIConnectionError, anthropic.APITimeoutError) as e: attempt += 1 if attempt > self.max_retries: log.error("stream failed after %d retries: %s", attempt, e) raise backoff = min(2 ** attempt, 30) log.warning("stream disconnected (attempt %d): %s. retrying in %ds", attempt, e, backoff) await asyncio.sleep(backoff) # Rebuild the request so Claude continues from where we left off create_kwargs = self._rebuild_kwargs(create_kwargs) def _update_state(self, event) -> None: et = event.type if et == "message_start": self.state.message_id = event.message.id self.state.model = event.message.model elif et == "content_block_start": self.state.current_block_index = event.index elif et == "content_block_delta": self.state.append_delta(event.index, event.delta.model_dump()) elif et == "message_delta": if event.delta.stop_reason: self.state.stop_reason = event.delta.stop_reason def _rebuild_kwargs(self, original: dict) -> dict: """Rebuild messages for reconnect: append the partial response as an assistant turn so Claude naturally continues it.""" rebuilt = dict(original) partial_text = self.state.text() if not partial_text: return rebuilt messages = list(rebuilt.get("messages", [])) messages.append({"role": "assistant", "content": partial_text}) rebuilt["messages"] = messages return rebuilt
Three things matter. First, the wrapper retries automatically on disconnect. Second, on retry it appends everything it received so far as an assistant message, which lets Claude continue the response deterministically. Third, all the bookkeeping lives in StreamState, so the caller sees a normal "one complete response" API.
Why continue via the message history instead of asking the API to resume? Claude's streaming API doesn't offer event-level resume, so reconnecting with the same request ID won't give you deduplicated replay guarantees. The history-based approach is idempotent — you spend a few more tokens, but the result is reproducible.
Absorbing duplicate events
Between HTTP/2 retries, proxy behavior, and client-side reconnect bugs, you will eventually see the same content_block_delta arrive twice. Users notice the moment text is duplicated, so you need to dedupe.
from hashlib import sha256class DedupBuffer: """Detect duplicated SSE events and drop the second copy.""" def __init__(self, window_size: int = 512): self.seen: set[str] = set() self.order: list[str] = [] self.window_size = window_size def _key(self, event) -> str: # message_id + block index + cumulative delta position is unique enough body = f"{event.message_id}:{event.index}:{event.position}" return sha256(body.encode()).hexdigest() def is_duplicate(self, event) -> bool: k = self._key(event) if k in self.seen: return True self.seen.add(k) self.order.append(k) if len(self.order) > self.window_size: old = self.order.pop(0) self.seen.discard(old) return False
The detail that matters: pick a dedup key that doesn't change on retry. Timestamps change. The tuple of message_id, block index, and cumulative position is the most stable key I've found.
Detecting incomplete tool_use safely
Tool arguments stream in as input_json_delta, which means you receive fragments of a JSON string. If the stream dies mid-block, your partial_json is not valid JSON — and json.loads on it throws.
import jsondef safe_parse_tool_input(block: dict) -> Optional[dict]: """Parse tool_use partial_json safely. Returns None if incomplete.""" raw = block.get("partial_json", "").strip() if not raw: return None try: return json.loads(raw) except json.JSONDecodeError as e: log.warning("incomplete tool input: %s (raw=%r)", e, raw[:200]) return None
Replace "can we run this tool call?" with "is safe_parse_tool_input non-None?" and you prevent a specific class of production accident: executing a tool with half the arguments. I once nearly wrote a partial row to a production database because of this. A single helper stops that category of bug cold.
Backoff strategies for rate limits vs. connection errors
If you treat rate limits and connection errors the same way, your retries will stampede right back into the 429 that caused them. Split them:
import randomasync def smart_sleep(attempt: int, error: Exception) -> float: """Pick a backoff delay based on the error type and sleep.""" if isinstance(error, anthropic.RateLimitError): # Respect the server's Retry-After and add jitter retry_after = getattr(error, "retry_after", None) or 30 delay = retry_after + random.uniform(0, 5) elif isinstance(error, (anthropic.APIConnectionError, anthropic.APITimeoutError)): # Connection issues: shorter exponential with jitter delay = min(2 ** attempt, 20) + random.uniform(0, 1) else: delay = min(2 ** attempt, 60) log.info("sleeping %.2fs before retry (attempt=%d, error=%s)", delay, attempt, type(error).__name__) await asyncio.sleep(delay) return delay
The jitter matters. Without it, many clients recover from 429 in lockstep and re-trigger the same limit (the thundering herd). A few seconds of randomness flattens the peak noticeably.
Three pitfalls I keep seeing in code review
Pitfall 1: forgetting to close the stream on early break
# BAD: breaking early leaks the connectionstream = client.messages.stream(**kwargs)async for event in stream: if something: break
Use the context manager (async with). It closes the stream even when you break out of the loop. I once spent days tracking down a "connections leak every 30 minutes" issue that came down to this single line.
Pitfall 2: concatenating the full text on every delta
# BAD: every event re-renders an ever-growing stringaccumulated = ""async for event in stream: if event.type == "content_block_delta": accumulated += event.delta.text render(accumulated)
On long responses this becomes O(n^2). Keep deltas in a list and join them on a render tick (requestIdleCallback in the browser, a coalescing task in Python/Node).
Pitfall 3: confusing the error event with an exception
Claude returns error events inside an otherwise HTTP-200 SSE body. The official SDK raises them for you, but if you are rolling your own HTTP client you have to check event.type == "error" yourself.
async for event in stream: if event.type == "error": err = event.error log.error("api error in stream: %s (%s)", err.message, err.type) if err.type == "overloaded_error": raise anthropic.APIStatusError(err.message) # retryable raise RuntimeError(f"fatal api error: {err}")
Only overloaded_error is worth retrying. Everything else should fail loud — otherwise a prompt bug becomes an infinite retry loop.
What to monitor once you ship
Hardening the code isn't enough. If you can't see failure, your users will find it first. Our team watches four metrics for every streaming endpoint:
Completion rate — fraction of streams that reach message_stop (target: 99.5%+)
Avg reconnects per stream — alert above 0.1
P95 time-to-first-token — alert above 6 seconds
Duplicate event rate — fraction of events the dedup buffer dropped (keep under 1%)
Pipe these into Datadog or Grafana and you'll catch regressions before support tickets do.
Looking back
Production resilience for the Claude API is easiest to reason about in two layers.
The first is the per-request layer: circuit breakers to cut failure chains, model routing and fallback chains to keep answers flowing, exponential backoff for safe retries, and health checks to watch the whole thing. Bundled into a ResilienceManager, they give you high availability without thinking about each pattern individually.
The second is the streaming layer: a stateful wrapper that preserves partial responses, deduplication that assumes events repeat, and backoff that branches on error type.
The shared stance is not "build so it never breaks" but "build so that breakage is quiet and recoverable." Start with circuit breakers and retries, then convert one streaming callsite to ResilientStream, then add a single monitoring metric. That order raises availability one step at a time. For complementary cost work, see Claude API Cost Optimization Production Guide.
Share
Thank You for Reading
Claude 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.