Handle Network Errors
When the Request Never Arrives
Most errors from the service come back as a status code, which tells the caller what happened. Network errors are different. They happen between the caller and the service, so the caller may never learn whether the request reached the server, was processed, or was lost. The SDK reports them as APIConnectionError or APITimeoutError (Lesson 141), and those classes carry no status code, no body, and no request ID.
That silence is what makes network errors hard to reason about. A status code tells you the server's decision. A network error tells you only that no decision reached you. This lesson explains the kinds of network failure, how to tell them apart from the underlying exceptions, what each one means for the work in progress, and how to recover partial answers when a response is cut off partway through.
Where the Failure Happens
A request crosses several layers, and a failure can happen at any of them:
application ──► DNS lookup ──► TCP connect ──► TLS handshake ──► send request ──► receive response
│ │ │ │ │
"cannot find "refused or "certificate or "connection "connection
the host" unreachable" handshake fails" reset" dropped mid-reply"
The SDK does not expose each of these as its own exception. It wraps the lower-level errors from the HTTP library, and keeps the original as the error's __cause__. That chain is the most useful diagnostic information a network error carries.
Reading the Cause Chain
When the SDK raises APIConnectionError, Python stores the underlying exception in error.__cause__. Checking that attribute tells you which layer failed. The httpx exception types map to the layers above:
| Cause | Typical meaning | What it suggests |
|---|---|---|
httpx.ConnectError | The host could not be reached: lookup failure, refused connection, or unreachable network | Check DNS, proxy, firewall, and general connectivity |
httpx.ReadError, httpx.RemoteProtocolError | The connection closed before the response finished | A transient drop, or a server that restarted mid-request |
httpx.TimeoutException subclasses | A phase exceeded its timeout (Lesson 144) | The server or network is slow, or the limit is too short |
None | No further detail was recorded | Treat as a generic network problem |
The cause chain turns a vague "connection error" into a specific question. A ConnectError on every request from one machine suggests a local network problem, while the same error from every machine suggests an outage elsewhere. The classifier below encodes those checks:
"""network_errors.py: classify network failures from the SDK's cause chain."""
import httpx
from anthropic import APIConnectionError, APITimeoutError
REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
def classify_network(error: Exception) -> str:
cause = error.__cause__
if isinstance(error, APITimeoutError):
return "timeout: no answer within the time limit"
if isinstance(cause, httpx.ConnectError):
return "unreachable: lookup, routing, or connection refused"
if isinstance(cause, (httpx.ReadError, httpx.RemoteProtocolError)):
return "dropped: the connection closed before the response finished"
if cause is not None:
return f"network problem: {type(cause).__name__}"
return "network problem: no further detail"
def with_cause(error: Exception, cause: Exception) -> Exception:
error.__cause__ = cause
return error
if __name__ == "__main__":
print(classify_network(APITimeoutError(request=REQUEST)))
print(classify_network(with_cause(APIConnectionError(request=REQUEST),
httpx.ConnectError("name resolution failed"))))
print(classify_network(with_cause(APIConnectionError(request=REQUEST),
httpx.RemoteProtocolError("server disconnected"))))
print(classify_network(APIConnectionError(request=REQUEST)))
Output:
timeout: no answer within the time limit
unreachable: lookup, routing, or connection refused
dropped: the connection closed before the response finished
network problem: no further detail
How It Works
- The timeout check comes first, because
APITimeoutErroris a subclass ofAPIConnectionError. Checking the parent first would hide the more specific class. - The cause is read from
__cause__, the standard Python link to the exception that led to the current one. The SDK sets it when it wraps the HTTP library's error. - Each
httpxtype maps to one diagnosis. Connection refusals and lookup failures point to reachability. Closed connections point to something interrupting the reply. with_cause()builds the cause chain for tests, so each diagnosis can be checked without a real network.- Unknown causes still produce a message, so a log never ends up with an empty explanation.
What Happened to the Work
A network error leaves one question open: did the server process the request? The answer changes what the caller should do:
| Situation | Server processed it? | Retry safety |
|---|---|---|
| Connection refused, or lookup failed | No, the request never arrived | Safe to retry |
| Connection dropped before any data arrived | Unknown, possibly not | Retry, and accept the small chance of a duplicate |
| Connection dropped after part of the answer arrived | Yes, the server was generating | Keep the partial answer, and continue rather than repeat |
| Timeout on a non-streamed request | Possibly yes, and the answer may be lost | Retry only if the operation is safe to repeat (Lesson 143) |
The middle rows are where duplicates come from. If the server finished the work and only the reply was lost, a retry does the work again. That is acceptable for an answer that is only read, and dangerous for an action that changes state. Lesson 143's idempotency rules decide which case applies.
Recovering a Partial Answer
Streaming makes partial answers common. A stream delivers text as it is generated, so a drop in the middle leaves the caller with the beginning of the answer. Discarding that text and starting over wastes the tokens already produced, and risks a different answer. A better approach keeps what arrived and asks the model to continue from it.
The helper below collects a stream and reports whether it finished. When it did not, the partial text is returned along with a flag, so the caller can decide how to proceed:
class FlakyStream:
"""Yields chunks, then fails partway through, the way a dropped connection does."""
def __init__(self, chunks: list[str], fail_after: int):
self.chunks = chunks
self.fail_after = fail_after
def __iter__(self):
for index, chunk in enumerate(self.chunks):
if index == self.fail_after:
raise APIConnectionError(request=REQUEST)
yield chunk
def collect(stream) -> tuple[str, bool]:
received = []
try:
for chunk in stream:
received.append(chunk)
return "".join(received), True
except APIConnectionError:
return "".join(received), False
if __name__ == "__main__":
text, complete = collect(FlakyStream(["The answer ", "is 42", "."], fail_after=2))
print(repr(text), "complete:", complete)
Output:
'The answer is 42' complete: False
How It Works
- The stream yields chunks until the failure point, then raises the same error a dropped connection would produce.
collect()keeps every chunk that arrived, in order, in the list. The exception is caught only to record that the stream did not finish.- The return value carries both pieces of information: the text received, and whether it is complete. Callers cannot accidentally treat a partial answer as a finished one, because the flag must be checked.
- The example stops at two chunks, which is why the text reads as an unfinished sentence, and the flag is
False.
Continuing a Cut-Off Answer
Once the partial text is safe, the next step is to ask Claude to continue. On current models, prefilling, which means ending the messages with a partial assistant turn, is not supported. The continuation must therefore be phrased as a request: the question, the partial answer, and an instruction to carry on from where the answer stopped.
def continuation_messages(question: str, partial: str) -> list[dict]:
return [
{"role": "user", "content": question},
{"role": "user", "content":
f"Your answer was cut off. Continue from exactly where it stopped, without "
f"repeating what you already wrote.\n\nAnswer so far:\n{partial}"},
]
messages = continuation_messages("Explain what a token is.", "A token is a small piece of")
for message in messages:
print(message["role"], "->", message["content"].split(".")[0])
Output:
user -> Explain what a token is
user -> Your answer was cut off
How It Works
- The original question is kept, so the continuation has the same context as the first attempt.
- The partial answer is quoted in the instruction, so Claude can see exactly where it stopped. The instruction tells it not to repeat what was already written.
- The example uses two user messages, to keep the original question distinct from the instruction. Because consecutive messages with the same role are a sensitive detail of the API's message rules, check the current message-format documentation, and merge the two into one message if your SDK version requires it. A single combined message carries the same content.
- Nothing in the request puts words in Claude's mouth. The continuation is driven by an explicit instruction, which current models follow reliably.
Note: a continuation is a new request, so it is billed, and it may not reproduce the same wording the first attempt would have produced. Keep the partial answer alongside the continuation, so the final text can be assembled from both.
Diagnosing Persistent Network Problems
Transient failures pass, and a retry layer covers them (Lesson 143). A problem that persists across many attempts calls for diagnosis. Two checks answer most questions: whether the failure is specific to one machine, and whether the status page reports an incident. A failure on every machine points to the service, and a failure on one machine points to that machine's network, proxy, or certificates.
A proxy that rewrites or blocks encrypted traffic can also cause TLS errors that look like connection errors, and the cause chain usually shows the difference. Certificate problems appear as handshake failures before any request is sent, while a dropped response appears only after the request was sent.
Summary
Network errors carry no status code, so the cause chain is the main evidence. Each layer of the request can fail, and each failure leaves a different question about whether the server processed the work. Retries cover the transient cases, and idempotency decides whether repeating is safe. When a stream is cut off, the text already received is valuable, and a continuation request can finish the answer without discarding the tokens already paid for.