Handle Stream Errors
Why Streams Need Special Error Handling
A normal request either succeeds or fails as a whole. A stream can fail at two different moments, with different consequences:
| When it fails | What you have | Typical causes |
|---|---|---|
| Before the stream starts | Nothing: no events were received | Invalid key, invalid request, rate limit, network problem, server error |
| During the stream | A partial response: some text was already shown to the user | Network interruption, error event mid-stream (such as overloaded_error) |
The second case is unique to streaming. The user may already have read half an answer when the error occurs. Good error handling must decide what to do with that partial content: discard it, keep it, or continue from it.
Errors Before the Stream Starts
With client.messages.stream(...), the HTTP request is sent when the with block is entered. Errors in the request itself are raised at that point, as the same exception classes as non-streaming requests (Lesson 1):
import anthropic
try:
with client.messages.stream(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
except anthropic.AuthenticationError:
print("Invalid API key.")
except anthropic.RateLimitError:
print("Rate limited. Please wait and try again.")
except anthropic.APIConnectionError:
print("Could not connect to the API.")
except anthropic.APIStatusError as error:
print(f"API error {error.status_code}.")
The SDK's automatic retries (Lesson 10) apply here. Retryable failures of the initial request, such as connection errors, 429, and 5xx, are retried before an exception reaches your code.
Errors During the Stream
Once events are flowing, two kinds of failure can occur.
Error Events
According to the streaming documentation, the API can occasionally send an error event in the middle of a stream. For example, during periods of high usage, an overloaded_error can arrive, which would normally correspond to an HTTP 529 in a non-streaming request:
event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
The SDK turns error events into exceptions raised from the loop you are iterating.
Interrupted Connections
The network connection can also drop mid-stream. Depending on where the failure happens, it can surface as an anthropic.APIConnectionError or as an exception from the underlying HTTP library. Robust code therefore treats unexpected exceptions raised during iteration as interruptions, not only anthropic.APIError.
Mid-Stream Errors Are Not Retried Automatically
Automatic retries cover the initial request. Once part of the response has been delivered, the SDK cannot transparently retry, because your code has already received, and possibly displayed, part of the answer. Your application must decide what to do.
Three Ways to Handle an Interrupted Stream
| Strategy | What happens | Best for |
|---|---|---|
| Discard and report | Show an error; do not store the partial answer; let the user retry | Short answers; simple applications |
| Retry from the start | Clear the partial output and send the same request again | Short answers, or when the partial output contained thinking or tool calls |
| Continue from the partial text | Send a continuation request and append the rest | Long text answers, where restarting would waste time and tokens |
Continuing on Current Models
The documentation describes a capture-and-resume strategy:
- Capture the content received before the error.
- Construct a continuation request.
- Resume streaming and receive the rest.
For Claude 4.6 and later, step 2 uses a user message that contains the partial response and an instruction to continue, because these models do not support a trailing assistant message (prefill). The documentation suggests a prompt along these lines:
Your previous response was interrupted and ended with [previous_response]. Continue from where you left off.
Limitations: only text can be resumed this way. According to the documentation, tool use and thinking blocks cannot be partially recovered. You can resume from the most recent text block. If a stream fails while a tool call is being generated, restart the request instead.
Retrying Before Any Text Arrived
If the stream fails before any text was received, nothing was shown to the user, and the simplest recovery is to try again after a short delay, with a limit on attempts:
import time
for attempt in range(3):
try:
... # stream the response
break
except anthropic.APIError:
if attempt == 2:
raise
time.sleep(2 ** attempt) # 1s, then 2s
2 ** attempt creates exponential backoff: each wait is longer than the last, giving a busy service time to recover.
Practical Example: A Resilient Streaming Function
This function streams an answer and recovers from interruptions automatically:
- If the stream fails before any text arrives, it retries the original request with backoff.
- If the stream fails after some text has arrived, it sends a continuation request and streams the rest.
- It stops after a fixed number of attempts, and always tells the caller whether the answer is complete.
"""resilient_stream.py: stream an answer and recover from mid-stream interruptions."""
import time
from dataclasses import dataclass
import anthropic
from anthropic import Anthropic
client = Anthropic()
MODEL = "claude-opus-5-5"
MAX_ATTEMPTS = 3
NOT_RETRYABLE = (anthropic.AuthenticationError, anthropic.PermissionDeniedError,
anthropic.BadRequestError, anthropic.NotFoundError)
@dataclass
class StreamResult:
text: str
complete: bool
attempts: int
stop_reason: str | None
def with_continuation(messages: list[dict], partial: str) -> list[dict]:
"""Return a request that asks Claude to continue a partial answer (Claude 4.6+ pattern)."""
last = messages[-1]
continuation = (
f"{last['content']}\n\n"
"Your previous response was interrupted and ended with:\n"
f"<previous_response>\n{partial}\n</previous_response>\n"
"Continue from exactly where you left off. Do not repeat earlier text."
)
return messages[:-1] + [{"role": "user", "content": continuation}]
def resilient_stream(messages: list[dict], max_tokens: int = 4096) -> StreamResult:
received = ""
request_messages = messages
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
with client.messages.stream(
model=MODEL, max_tokens=max_tokens, messages=request_messages
) as stream:
for text in stream.text_stream:
received += text
print(text, end="", flush=True)
final = stream.get_final_message()
return StreamResult(received, final.stop_reason == "end_turn", attempt, final.stop_reason)
except NOT_RETRYABLE:
raise
except Exception as error:
print(f"\n[interrupted ({type(error).__name__}); attempt {attempt} of {MAX_ATTEMPTS}]")
if attempt == MAX_ATTEMPTS:
break
time.sleep(2 ** (attempt - 1))
if received:
request_messages = with_continuation(messages, received)
return StreamResult(received, False, MAX_ATTEMPTS, None)
if __name__ == "__main__":
result = resilient_stream(
[{"role": "user", "content": "Explain how Python's garbage collector works, in about 300 words."}]
)
print(f"\n\n[complete={result.complete}, attempts={result.attempts}, stop_reason={result.stop_reason}]")
Example output when the connection drops once:
Python manages memory mainly through reference counting: every object keeps a count of
[interrupted (APIConnectionError); attempt 1 of 3]
how many references point to it, and when that count drops to zero, the object is freed...
[complete=True, attempts=2, stop_reason=end_turn]
How It Works
NOT_RETRYABLElists errors that retrying cannot fix: an invalid key, missing permissions, an invalid request, or an unknown model. They are re-raised immediately, so they are not hidden behind repeated attempts. Because these classes are listed in anexceptclause before the generalexcept Exception, Python checks them first.- Every other exception during the stream, whether an API error event, a connection failure, or a timeout, is treated as an interruption.
- Backoff:
time.sleep(2 ** (attempt - 1))waits 1 second, then 2 seconds, before the next attempt. - No text received yet:
receivedis empty, sorequest_messagesis still the original request, and the same request is sent again. That is a simple retry. - Some text received:
with_continuation()builds a new request. The last user message is extended with the partial answer, inside<previous_response>tags, and an instruction to continue. There is no trailing assistant message, as required on Claude 4.6 and later. The function always builds the continuation from the originalmessages, together with all text received so far, so a second interruption is handled the same way as the first. - New text is appended to
received, so the finaltextis the original part followed by the continuation. StreamResultalways reports whether the answer iscomplete. Callers never mistake a partial answer for a finished one.
Limitations to keep in mind:
- The join point may not be perfect: the continuation can occasionally repeat or skip a few words. For critical content, consider regenerating instead.
- The continuation only works for text. For requests with tools, or when you need thinking blocks, restart the request instead.
- In a conversation, store the combined text as one assistant turn (Lesson 25), not the continuation scaffolding.
Common Mistakes
Catching Errors Only Around the with Line
What happens: errors raised during iteration escape the handler.
Fix: put the entire with block, including the loop and get_final_message(), inside the try.
Retrying Errors That Cannot Succeed
What happens: invalid keys or bad requests are retried several times, wasting time.
Fix: re-raise authentication, permission, validation, and not-found errors immediately.
Restarting After Showing Partial Text
What happens: the user sees the beginning of the answer twice.
Fix: continue from the partial text, or clear the displayed output before restarting.
Continuing with a Trailing Assistant Message
What happens: a 400 error on Claude 4.6 and later models, which do not support prefill.
Fix: put the partial text and the continuation instruction in a user message.
Treating Partial Answers as Complete
What happens: truncated content is stored or displayed as final.
Fix: track completeness explicitly, and tell users when an answer is incomplete.
Best Practices
- Wrap the whole stream (entry, iteration, and final message) in error handling.
- Separate retryable from non-retryable errors.
- Retry with exponential backoff and a fixed attempt limit.
- Continue long text answers with a user-message continuation on current models.
- Restart when the partial content included tool calls or thinking you need.
- Always report completeness to callers and users.
- Log interruptions, with request IDs where available (Lesson 49).
Exercise
- Simulate an interruption: in the loop, raise an exception after receiving 200 characters, but only on the first attempt. Confirm that the continuation produces a complete answer.
- Change
resilient_stream()to restart from the beginning, instead of continuing, when fewer than 100 characters had been received. Remember to resetreceivedin that case. - Add a
request_idfield toStreamResult, filled from the final message. - When the error is an
anthropic.APIStatusError, print its request ID (Lesson 49) in the interruption message.
Step 1:
simulated = {"done": False}
# Inside the text loop:
received += text
print(text, end="", flush=True)
if not simulated["done"] and len(received) > 200:
simulated["done"] = True
raise RuntimeError("simulated network interruption")
The first attempt fails after about 200 characters, and the second attempt continues from that point.
Step 2: in the except Exception block, replace the continuation logic:
if len(received) < 100:
print("[restarting from the beginning]")
received = ""
request_messages = messages
else:
request_messages = with_continuation(messages, received)
Restarting is simpler and gives a cleaner answer when little was lost. Resetting received prevents the short partial text from being duplicated in the final result. In an interactive display, also clear or mark the partial text the user already saw.