
Take Your Claude Sonnet 5 Integration to Production: Streaming, Stop Reasons, Retries and Errors
Your happy-path Sonnet 5 call breaks in prod: 128k output times out without streaming, unhandled stop reasons silently drop work, and transient 429/5xx aren't retried. Harden it: stream and use get_final_message, branch on every stop_reason, catch typed exceptions (auto-retry 429+5xx, never 4xx), pass an idempotency key, and add a queue with jitter at scale.
Why does the happy-path call break in production?
The happy-path Sonnet 5 call works on your laptop and breaks under real traffic in four ways: long output times out without streaming, an unhandled stop_reason silently drops work, transient 429/5xx drop requests, and missing idempotency double-charges on retry. Six concrete habits fix it.
Here is the call everyone copies from the docs. It works the first time, so you ship it:
import anthropic
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
messages=[{"role": "user", "content": "Summarize this contract..."}],
)
print(message.content[0].text)
On your laptop this is fine. The output is short, you fire one request at a time, and nothing transient happens between you and Anthropic. None of that holds in production, and this exact snippet breaks four different ways once real traffic hits it.
Failure 1: long output times out. Sonnet 5 maxes at 128k output tokens (300k on the Batch API with the output-300k-2026-03-24 beta header). Non-streaming requests risk SDK HTTP timeouts above roughly 16k max_tokens, so anything that might produce a long answer needs streaming — create() will hang and die.
Failure 2: an unhandled stop_reason silently drops work. Reading content[0].text blindly assumes the model finished cleanly. It might have stopped to call a tool, hit max_tokens, paused on a server-side tool, or refused. In every one of those cases your code reads garbage or crashes, and you never notice because there’s no exception.
Failure 3: transient 429/5xx kill the request. Rate limits and overloads are normal at scale, not exceptions. A single unhandled 429 drops a user’s request on the floor.
Failure 4: no idempotency. An agent that re-sends on a timeout double-charges and double-acts — it can pay an invoice twice or send the same email twice.
The rest of this post turns that brittle call into one that survives. It’s six concrete habits, not a rewrite. And one honest note up front: Sonnet 5 launched June 30, 2026 — it’s brand-new, so confirm every shape against the current docs as you go. This is the production layer on top of the Sonnet 5 agent you’re hardening and the broader production context.
Why must you stream Sonnet 5’s long output?
The first hardening step is streaming, and it’s not optional for this model. Non-streaming requests risk SDK HTTP timeouts above roughly 16k max_tokens, and Sonnet 5 can emit up to 128k. The math is simple: any route that might produce a long answer essentially requires streaming or it will time out.
The clean pattern is client.messages.stream(...) as a context manager, then get_final_message() to get the complete Message object — no event handlers required:
import anthropic
client = anthropic.Anthropic(max_retries=4)
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=64000,
thinking={"type": "adaptive"},
messages=[{"role": "user", "content": "Draft the full migration plan..."}],
) as stream:
message = stream.get_final_message()
print(message.stop_reason, message.usage)
Note the exact shapes. model="claude-sonnet-5" (dateless pinned snapshot). thinking={"type": "adaptive"} — adaptive thinking only. There is NO budget_tokens, and NO temperature, top_p or top_k on this model; passing them is a 400, not a tuning knob.
You also control cost and depth with output_config.effort (low | medium | high | max), which DEFAULTS to high on Sonnet 5. Higher effort means more thinking, more tokens, more latency and more money — re-tune it per route rather than leaving everything on high. In TypeScript the equivalent is .finalMessage(). The streaming docs cover both, and streaming is also what lets you handle pause_turn cleanly for server-side tools, which is next.
Did you branch on every stop_reason?
Streaming gets you a complete Message, but you still have to read message.stop_reason before you trust the content. There are five values and each needs its own branch. Skipping this dispatch is the silent-failure bug from section 1.
def handle(message, client, conversation):
reason = message.stop_reason
if reason == "end_turn":
return message.content # finished cleanly — safe to read
if reason == "tool_use":
# run the requested tool, append the result, continue the loop
return run_tools_and_continue(client, conversation, message)
if reason == "max_tokens":
# output was cut off — raise max_tokens or continue streaming
raise OutputTruncated("raise max_tokens or continue the turn")
if reason == "pause_turn":
# a server-side tool paused — re-send the conversation to resume
conversation.append({"role": "assistant", "content": message.content})
return resume(client, conversation)
if reason == "refusal":
# safety decline — handle explicitly, do NOT read content as a result
return reject_safely(message)
raise ValueError(f"unknown stop_reason: {reason}")
end_turn is the only one where reading content[0].text is correct. tool_use means run the tool and continue. max_tokens means you were cut off — raise the limit or keep streaming. pause_turn means a server tool paused and you re-send to resume. refusal is a safety decline; treat it as a handled outcome, not a payload. If you only handle the happy path, the other four silently corrupt your output.
Which errors do you retry, and which do you fix?
The third habit is drawing the retry-vs-fix line correctly. Half the error map is transient and worth retrying; the other half is your own bug and retrying never helps — it just burns money and latency.
RETRY (transient): 429 rate_limit_error (honor the retry-after header), 500 api_error, and 529 overloaded_error. Connection drops and timeouts (APIConnectionError, APITimeoutError) are transient too — and a 504 timeout_error on a long request is exactly the case the docs tell you to fix with streaming.
FIX (your bug): 400 invalid_request_error, 401 authentication_error, 403 permission_error, 404 not_found_error, and 413 request_too_large. Retrying any of these is pointless.
The official SDKs already auto-retry 429 and 5xx with exponential backoff (default max_retries = 2; raise it on the client). Your job is to catch the typed exception classes — anthropic.RateLimitError and the broader anthropic.APIStatusError, which expose .status_code and .type. NEVER string-match error messages; they change.
import anthropic
client = anthropic.Anthropic(max_retries=4)
try:
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=64000,
thinking={"type": "adaptive"},
messages=conversation,
extra_headers={"Idempotency-Key": request_id},
) as stream:
# a late SSE error surfaces HERE, during consumption — keep it in the try
message = stream.get_final_message()
except anthropic.RateLimitError as e:
# SDK already backed off max_retries times — now it's on you (queue it)
retry_after = getattr(e, "response", None) and e.response.headers.get("retry-after")
enqueue_with_jitter(request_id, retry_after=retry_after)
except (anthropic.APIConnectionError, anthropic.APITimeoutError):
enqueue_with_jitter(request_id) # transient connection/timeout — retry
except anthropic.APIStatusError as e:
if e.status_code in (500, 529):
enqueue_with_jitter(request_id) # transient — safe to retry
else:
log.error("non-retryable: %s %s", e.status_code, e.type) # 4xx — fix the code
raise
One Sonnet 5 gotcha: a 404 not_found_error on the request almost always means a typo’d model id. The id is exactly claude-sonnet-5 — dateless pinned snapshot, no date suffix. A streaming caveat too: an error can surface after the 200 SSE response begins, raising during get_final_message() (or when you iterate text_stream), which is why both opening and consuming the stream live inside the same try. The full error reference lists every type.
RETRY (transient)
- 429 rate_limit_error — honor retry-after
- 500 api_error
- 529 overloaded_error
- 504 timeout_error — stream long requests
- SDK auto-retries 429/5xx with backoff
FIX (your bug)
- 400 invalid_request_error
- 401 authentication_error
- 403 permission_error
- 404 not_found_error — typo'd model id
- 413 request_too_large
How do you survive rate limits at scale?
The SDK’s two default retries cover a hiccup, not a sustained load spike. At volume, 429s stop being rare events and become a normal operating condition you have to design around.
A 429 carries a retry-after header plus x-ratelimit-* headers telling you exactly when capacity returns. Add an application-level queue with jitter so your retries don’t synchronize into a thundering herd — every dropped request waking up at the same millisecond just triggers the next 429. Ramp traffic gradually and keep your usage pattern consistent; sudden bursts can trip acceleration-limit 429s even when you’re under your nominal ceiling.
For anything that isn’t latency-sensitive, move it off the live path entirely. The Message Batches API runs at 50% of standard token prices, accepts up to 100k requests or 256 MB per batch, keeps results available for 29 days, and unlocks 300k output with the output-300k-2026-03-24 beta header (most batches finish in under an hour). Offline summarization, backfills, evals — all of it belongs in batch.
Prompt caching trims input cost on repeated stable prefixes: cache_control: {"type": "ephemeral"} at the end of the stable prefix, max 4 breakpoints, a read at roughly 0.1x input price and a write at 1.25x (5-minute TTL) or 2x (1-hour). Verify it landed via usage.cache_read_input_tokens. One caution — the minimum cacheable prefix is model-dependent, so confirm Sonnet 5’s exact minimum in the prompt caching docs rather than hardcoding a number.
Why pass an idempotency key on agent retries?
The fourth habit is cheap insurance against the most expensive class of bug. When an agent re-sends after a timeout — and timeouts happen — it can duplicate both the work and the charge. An Idempotency-Key deduplicates the retry server-side so the same logical request runs once.
import uuid
request_id = str(uuid.uuid4()) # one key per logical request, reused on retry
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=64000,
thinking={"type": "adaptive"},
messages=conversation,
extra_headers={"Idempotency-Key": request_id},
) as stream:
message = stream.get_final_message()
Pass it on any request you might retry — including the SDK’s own auto-retries combined with your queue logic. It matters most for tool-calling agents whose actions have side effects: writes, payments, emails. Pair idempotency with the typed-exception handling above so retries are both safe (deduplicated) and correctly scoped to transient errors only. If you’re shipping an agent, set up the minimum eval before this goes live so you catch regressions in the retry path too.
- Stream with get_final_messagerequired above ~16k max_tokens; Sonnet 5 does 128k
- Branch on stop_reasonend_turn / tool_use / max_tokens / pause_turn / refusal
- Typed-exception handlingcatch RateLimitError / APIStatusError; raise max_retries
- Idempotency key on retriesdedupe side-effecting agent actions server-side
- Queue + jitter at scalehonor retry-after; avoid the thundering herd
- Monitor usagetrack cache_read_input_tokens and effort per route
What does the full production checklist cost?
Stack the six habits: stream long output, branch on every stop_reason, retry only transient errors with typed exceptions, pass an idempotency key, queue with jitter, and monitor usage. That’s the whole hardening layer.
Now the honest cost lens, which is the part the launch posts skip. Sonnet 5 is $3 / $15 per MTok standard, with an introductory $2 / $10 through August 31, 2026 (as of launch 2026, confirm current). Model your bill at the STANDARD $3 / $15 — the intro price is time-bound and your production traffic will outlive it. Budgeting at $2 / $10 means your costs jump 50% on September 1 with no change to your code.
Pick Sonnet 5 for what it is: the value tier, close to Opus 4.8 at lower cost, not the absolute frontier. It’s the right call for reliable production volume, not for bleeding-edge reasoning where you’d reach for the top model. To keep the bill honest, monitor usage.cache_read_input_tokens and watch effort per route — reserve effort: max for the routes where correctness genuinely beats cost, and let cheaper routes run lower. And because the model is brand-new, confirm every number against current docs. More patterns live in the AI-agent guides.
Quick FAQ
What’s the exact model id? claude-sonnet-5 — dateless pinned snapshot, no date suffix. A 404 not_found_error almost always means you typo’d it here.
Do I need streaming for short outputs? Not strictly, but anything that might be long needs it. Non-streaming risks timeouts above roughly 16k max_tokens, and Sonnet 5 goes to 128k.
Can I set temperature or budget_tokens? No. Sonnet 5 uses adaptive thinking only (thinking={"type": "adaptive"}). There’s no budget_tokens, temperature, top_p or top_k — passing them is a 400.
Does the SDK already retry for me? Yes — 429 and 5xx with backoff, default max_retries=2. But design your own queue, jitter and idempotency at scale; two retries won’t carry a sustained spike.
Should I budget at the intro price? No. Model at standard $3 / $15. The $2 / $10 intro ends August 31, 2026.
Ship it
The gap between “works on my laptop” and “survives production” is six concrete habits, not a rewrite. Stream long output and read get_final_message, branch on every stop_reason, retry only the transient errors with typed exceptions, stay idempotent, queue with jitter, and watch your usage. Do that and the brittle demo call becomes something you can put real traffic through.
Verify everything against the current Anthropic docs before you ship — Sonnet 5 is brand-new, and the exact shapes are what make this correct. If you’re still deciding whether to move, here’s what Sonnet 5 is and how to switch.