Your Claude API integration is streaming responses beautifully — until it isn't. The stream stops mid-sentence, sometimes with an error, sometimes in complete silence. You add a retry and it works once, then fails again. Sound familiar?
The maddening part is that "streaming stopped" can mean five completely different things, each requiring a different fix. Throwing retries at a max_tokens issue, or increasing timeouts for a rate-limit problem, won't help. Let's cut through this systematically.
Step One: Classify the Failure by Symptom
Before touching any code, identify which category you're in.
If an exception was raised:
429 RateLimitError → Rate limiting (Cause 2)
529 OverloadedError → Server overload (Cause 2, same fix)
400 Bad Request → Context window exceeded (Cause 3)
ReadTimeout / TimeoutError → Network timeout (Cause 1)
ECONNRESET → Connection reset (Cause 1)
If the stream ended silently (no exception):
stop_reason: "max_tokens" → max_tokens setting too low (Cause 4)
stop_reason: "end_turn" → Normal completion — working as expected
Events just stop arriving → Client-side implementation bug (Cause 5)
The fastest diagnostic step you can take right now: add stop_reason logging after every stream. I've seen developers spend hours on a "streaming bug" that was simply max_tokens: 256 on a request asking for a 2,000-word document.
Cause 1: Network Timeout (Most Common)
Long responses get cut by HTTP timeout — your client gives up waiting while Claude is still generating. Default timeouts in requests and httpx are typically 30–60 seconds, which is not enough for complex Claude responses.
import anthropic
import httpx
# Default: will timeout on long responses
client_default = anthropic.Anthropic()
# Production: explicit timeout tuning for streaming
client = anthropic.Anthropic(
timeout=httpx.Timeout(
connect=10.0, # Time to establish connection
read=300.0, # Time to receive data — this is what matters for streaming
write=10.0, # Time to send request
pool=10.0, # Connection pool timeout
)
)
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{"role": "user", "content": "Write a detailed technical report on..."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)read=300.0 (5 minutes) may seem generous, but complex Claude tasks legitimately take 2–3 minutes. Since adopting this in production, I've had zero timeout-related stream cuts.
Cause 2: Rate Limits and Server Overload (429 / 529)
429 RateLimitError means you've exceeded your requests-per-minute or tokens-per-minute quota. 529 OverloadedError is temporary server-side capacity pressure. Both recover with time — the correct response is exponential backoff with retry.
import anthropic
import time
def stream_with_retry(client, max_retries=3, **kwargs):
"""Streaming with exponential backoff for rate limits and overload."""
for attempt in range(max_retries):
try:
with client.messages.stream(**kwargs) as stream:
collected = ""
for text in stream.text_stream:
print(text, end="", flush=True)
collected += text
return collected
except anthropic.RateLimitError:
if attempt == max_retries - 1:
raise
wait = (2 ** attempt) * 30 # 30s → 60s → 120s
print(f"\n⚠️ Rate limited — waiting {wait}s ({attempt+1}/{max_retries})")
time.sleep(wait)
except anthropic.APIStatusError as e:
if e.status_code == 529 and attempt < max_retries - 1:
wait = (2 ** attempt) * 10
print(f"\n⚠️ Overloaded — waiting {wait}s ({attempt+1}/{max_retries})")
time.sleep(wait)
else:
raise
client = anthropic.Anthropic()
stream_with_retry(
client,
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{"role": "user", "content": "Hello!"}],
)If you're hitting rate limits frequently, check your tier in the Anthropic console. Tier 1 has lower TPM limits that can catch you off guard with concurrent requests. For a full breakdown of 429 handling, see Claude API 429 Error Handling Guide.
Cause 3: Context Window Exceeded (400 Error)
When total input tokens exceed the model's context window, you get a 400 Bad Request before streaming even starts — so the symptom is "stream never begins" rather than "stream stops mid-way."
The same issue occurs when input_tokens + max_tokens would exceed the limit.
import anthropic
client = anthropic.Anthropic()
def get_safe_max_tokens(messages, model="claude-sonnet-4-6", buffer=100):
"""Calculate safe max_tokens based on actual input length."""
count = client.messages.count_tokens(
model=model,
messages=messages,
)
input_tokens = count.input_tokens
# claude-sonnet-4-6: 200k context window, 64k max output
context_window = 200000
max_output = 64000
available = context_window - input_tokens - buffer
safe_tokens = min(available, max_output)
print(f"Input: {input_tokens} tokens / Safe max_tokens: {safe_tokens}")
return max(safe_tokens, 1)
messages = [
{"role": "user", "content": "Here is a very long document..."}
]
safe_tokens = get_safe_max_tokens(messages)
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=safe_tokens,
messages=messages,
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)If you're building a multi-turn chatbot and passing full conversation history, this becomes an issue around message 10–15. Consider summarizing older turns or using a sliding window of recent messages.
Cause 4: max_tokens Reached (Normal End, Wrong Setting)
This is not an error — it's expected behavior — but it looks like a bug because the response is cut short. The tell: stop_reason will be "max_tokens" instead of "end_turn".
import anthropic
client = anthropic.Anthropic()
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=256, # Too low for "explain in detail" requests
messages=[{"role": "user", "content": "Explain Python async/await in detail"}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
# Always check stop_reason after stream ends
final = stream.get_final_message()
print(f"\n\n[stop_reason: {final.stop_reason}]")
if final.stop_reason == "max_tokens":
print("⚠️ Response was cut short. Increase max_tokens.")
elif final.stop_reason == "end_turn":
print("✅ Response completed normally.")As a rule of thumb: for conversational responses, max_tokens=1024 is usually fine. For detailed technical content, use 4096 or higher. claude-sonnet-4-6 supports up to 64,000 output tokens.
Cause 5: Client-Side Implementation Bugs
When there's no error and stop_reason is end_turn, but the output still seems truncated, look at your client code.
Using sync client in async code:
import asyncio
import anthropic
# Wrong: using sync client in async context
async def wrong():
client = anthropic.Anthropic() # sync client
# This will block the event loop
# Correct: use AsyncAnthropic for async code
async def correct():
client = anthropic.AsyncAnthropic()
async with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
) as stream:
async for text in stream.text_stream:
print(text, end="", flush=True)
print()
asyncio.run(correct())Breaking out of the stream loop early:
# This cuts the stream — remaining events are lost
with client.messages.stream(...) as stream:
for text in stream.text_stream:
if some_condition:
break # Stream is closed here
# If you need the full text without streaming display, use get_final_message
message = client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
).get_final_message()
print(message.content[0].text)Production-Ready Streaming Implementation
Here's a battle-tested pattern combining all five fixes:
import anthropic
import httpx
import time
from collections.abc import Iterator
def create_client() -> anthropic.Anthropic:
"""Initialize client with production-appropriate timeouts."""
return anthropic.Anthropic(
timeout=httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0)
)
def stream_robust(
client: anthropic.Anthropic,
messages: list,
model: str = "claude-sonnet-4-6",
max_tokens: int = 4096,
max_retries: int = 3,
) -> Iterator[str]:
"""
Production streaming with automatic diagnosis and retry.
Handles: timeout, rate limit, overload.
Logs stop_reason for visibility.
"""
for attempt in range(max_retries):
try:
with client.messages.stream(
model=model,
max_tokens=max_tokens,
messages=messages,
) as stream:
for text in stream.text_stream:
yield text
final = stream.get_final_message()
if final.stop_reason == "max_tokens":
print(f"\n⚠️ Hit max_tokens ({max_tokens}). Consider increasing it.")
return # Normal completion
except anthropic.RateLimitError:
wait = 2 ** attempt * 30
if attempt < max_retries - 1:
print(f"\n⚠️ Rate limited — {wait}s backoff ({attempt+1}/{max_retries})")
time.sleep(wait)
else:
raise
except anthropic.APIStatusError as e:
if e.status_code == 529 and attempt < max_retries - 1:
wait = 2 ** attempt * 10
print(f"\n⚠️ Overloaded — {wait}s backoff ({attempt+1}/{max_retries})")
time.sleep(wait)
else:
raise
except (httpx.ReadTimeout, httpx.ConnectError) as e:
if attempt < max_retries - 1:
print(f"\n⚠️ Network error: {e} — retrying ({attempt+1}/{max_retries})")
time.sleep(5)
else:
raise
# Usage
client = create_client()
for text in stream_robust(
client,
messages=[{"role": "user", "content": "Explain async/await with examples"}],
):
print(text, end="", flush=True)For the full error handling reference, see Claude API Error Complete Guide.
TypeScript / Node.js Equivalent
If you're using the TypeScript SDK, the pattern is nearly identical:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
timeout: 300 * 1000, // 300 seconds in milliseconds
});
async function streamWithRetry(
messages: Anthropic.Messages.MessageParam[],
maxRetries = 3
): Promise<string> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
let collected = "";
const stream = await client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 4096,
messages,
});
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
process.stdout.write(event.delta.text);
collected += event.delta.text;
}
}
const finalMessage = await stream.finalMessage();
if (finalMessage.stop_reason === "max_tokens") {
console.warn("\n⚠️ Hit max_tokens. Consider increasing the limit.");
}
return collected;
} catch (error) {
if (error instanceof Anthropic.RateLimitError) {
const wait = Math.pow(2, attempt) * 30000;
if (attempt < maxRetries - 1) {
console.warn(`\n⚠️ Rate limited — waiting ${wait / 1000}s`);
await new Promise((r) => setTimeout(r, wait));
} else {
throw error;
}
} else if (
error instanceof Anthropic.APIStatusError &&
error.status === 529
) {
const wait = Math.pow(2, attempt) * 10000;
if (attempt < maxRetries - 1) {
console.warn(`\n⚠️ Overloaded — waiting ${wait / 1000}s`);
await new Promise((r) => setTimeout(r, wait));
} else {
throw error;
}
} else {
throw error;
}
}
}
throw new Error("Max retries exceeded");
}
// Usage
await streamWithRetry([{ role: "user", content: "Explain async/await" }]);One key difference: the Node.js SDK uses milliseconds for the timeout option, not seconds. It's easy to accidentally set timeout: 300 thinking you're setting 300 seconds, when you're actually setting 300 milliseconds (0.3 seconds). Always multiply by 1000.
A Note on Streaming vs. Polling
For very long-running tasks, you might also consider the Messages Batches API instead of streaming. Batches are fire-and-forget — you submit a request and poll for results later. This sidesteps all five streaming failure modes at the cost of real-time display. It's worth considering when:
- Responses routinely exceed 30,000 tokens
- You're processing many documents in parallel
- Real-time display is not required by the user experience
For interactive chat and live generation, streaming is still the right choice — just with the reliability patterns shown above.
When streaming stops unexpectedly, run through this checklist in order: check the error code → check stop_reason → review your client code. In my experience, adding stop_reason logging catches the majority of "mysterious" stream cuts immediately. Start there — it's a one-line addition that pays for itself the first time you debug a production incident.