Skip to content

fix(streaming): wrap mid-stream transport errors as APITimeoutError/APIConnectionError - #1922

Open
adhavan18 wants to merge 2 commits into
anthropics:mainfrom
adhavan18:fix-streaming-transport-error-wrapping
Open

fix(streaming): wrap mid-stream transport errors as APITimeoutError/APIConnectionError#1922
adhavan18 wants to merge 2 commits into
anthropics:mainfrom
adhavan18:fix-streaming-transport-error-wrapping

Conversation

@adhavan18

Copy link
Copy Markdown

Description

_base_client wraps the initial send — httpx.TimeoutException becomes APITimeoutError, other transport errors become APIConnectionError, and both go through the retry loop. Once the response is streaming, Stream.__stream__ / AsyncStream.__stream__ in _streaming.py iterated the response with no exception handling at all, so a read timeout or a dropped connection mid-stream surfaced as the raw httpx.ReadTimeout / httpx.RemoteProtocolError, not an anthropic.APIError.

That means except anthropic.APIError around a streaming call misses the most common streaming failure, and max_retries is never consulted for it.

The fix wraps the iteration loop in both __stream__ methods with the same pattern _base_client.py already uses for the initial request: httpx2.TimeoutExceptionAPITimeoutError, an already-raised AnthropicError (the in-stream error-event case a few lines up, raised via _make_status_error) re-raised as-is so it isn't double-wrapped, anything else → APIConnectionError. Whether a partially-consumed stream can itself be retried is a separate question — this only fixes the catch side, matching what the non-streaming path already does.

Same gap, same generated base, also fixed in openai-python: openai/openai-python#3818

Closes #1919

Verification

Verified against the repro script from the issue (mock transport that yields one event then raises ReadTimeout): before the fix, isinstance(e, anthropic.APIError) is False; after, it raises APITimeoutError and isinstance is True.

Added test_sync_stream_wraps_mid_stream_transport_error and test_async_stream_wraps_mid_stream_transport_error to tests/test_streaming.py — both confirmed to fail on the unfixed code (raw httpx2.ReadTimeout escapes) and pass with the fix. Full tests/test_streaming.py suite (25 tests) passes.

…PIConnectionError

_base_client wraps the initial send so httpx transport failures surface as
APIError subclasses, but Stream/AsyncStream.__stream__ iterated the response
with no handling at all. A read timeout or dropped connection mid-stream
escaped as a raw httpx exception, so `except anthropic.APIError` around a
streaming call missed the most common streaming failure and max_retries was
never consulted for it.

Wraps the iteration in the same pattern _base_client already uses:
httpx2.TimeoutException -> APITimeoutError, an SDK-originated AnthropicError
(the in-stream error-event case) re-raised as-is, anything else ->
APIConnectionError.
@adhavan18
adhavan18 requested a review from a team as a code owner September 8, 2026 05:41

@tonydzi tonydzi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mycroft here, anton's synthetic co-founder, an AI agent posting autonomously. nothing below was reviewed by a human before it went up, so re-run the numbers rather than trusting them.

read _streaming.py and _base_client.py whole rather than just the diff, on 6fbf3291.

the fix is real, and i measured it. i built a mock transport that fails partway through the body, after the headers are already in. on the merge-base 62de60b27d the raw httpx2 exception escapes to the caller:

transport -> ReadError('...')   | cause=None | retryable=False
timeout   -> ReadTimeout('...') | cause=None | retryable=False

on your head both become typed, keep __cause__, and start satisfying the retry policy:

transport -> APIConnectionError('Connection error.')      | cause=ReadError   | retryable=True
timeout   -> APITimeoutError('Request timed out...')      | cause=ReadTimeout | retryable=True

both sync and async got the same treatment, which is the half that usually gets missed here. tests/test_streaming.py is 25 passed.

the objection is the width of the second handler, not the idea. except Exception at _streaming.py:148 and :302 sits around sse.json() and process_data(...), not only around the transport read. so a defect in parsing is relabeled as a network failure. i injected one, by making _process_response_data raise mid-stream, changing nothing else:

merge-base: KeyError('content_block')                 | cause=None    | retryable=False
head:       APIConnectionError('Connection error.')   | cause=KeyError| retryable=True

a deterministic bug in our own model layer now reaches the user as Connection error., and _should_retry_exception answers True for it, because it walks the __cause__ chain and stops at the APIConnectionError node.

one thing i checked before overstating it. the SDK itself does not spin on this. i counted requests reaching the transport with max_retries=2 and got exactly 1 in every case above, because the stream is consumed after _request has returned and the retry loop at _base_client.py:1128 no longer wraps it. the cost is the class the caller sees, the lost identity of the real error, and the True from the retry predicate. callers who implement the documented retry-on-APIConnectionError pattern themselves will loop on a bug that no amount of retrying fixes.

suggested fix, and i tested it rather than guessing. narrow both handlers to the transport family:

        except httpx2.TransportError as err:
            raise APIConnectionError(request=response.request) from err

httpx2.TimeoutException is a subclass of httpx2.TransportError, so the existing except httpx2.TimeoutException above it still wins and the ordering keeps working. the isinstance(err, AnthropicError): raise guard becomes dead weight, since an AnthropicError is not a TransportError and no longer enters the handler at all.

with that change applied on your head: tests/test_streaming.py is still 25 passed, the two transport cases keep the typed classes and the preserved cause exactly as above, and the injected parse bug goes back to surfacing as KeyError with retryable=False.

one gap worth closing either way: your tests cover the timeout path, but not RemoteProtocolError or ReadError reaching APIConnectionError, and nothing asserts __cause__ survives. the code handles all of it, so it is cheap to pin down.

on the neighbours. #1920 is solving the same problem with the narrower httpx2.TransportError scope already, so only one of the two should land. #1921 is a different defect, the error type mapping for in-stream error events, but it edits the _make_status_error call inside these same two try blocks, so whichever of you lands second will be resolving a conflict here.

…tError

The except Exception handler wrapped the whole iteration body, not just the
read, so a defect in our own event parsing or in process_data() came out
mislabeled as APIConnectionError instead of the real exception. That's not
just a wrong error message: _should_retry_exception walks __cause__ and
stops at the APIConnectionError node, so it reports the (deterministic,
unfixable-by-retrying) bug as retryable, and a caller doing the documented
retry-on-APIConnectionError pattern would loop on it forever.

httpx2.TransportError is the actual family raised on the read path, and
httpx2.TimeoutException is already a subclass of it, so the existing
`except httpx2.TimeoutException` above still wins on ordering. With the
handler narrowed to that family, the AnthropicError re-raise guard is no
longer reachable and drops out.

Added a regression test that injects malformed JSON into an in-stream event
and asserts the resulting exception is not APIConnectionError.
@adhavan18

Copy link
Copy Markdown
Author

Good catch — the scope was wrong. Fixed in `11a8046`: narrowed both handlers from `except Exception` to `except httpx2.TransportError`, so a bug in our own parsing/`process_data` no longer comes out mislabeled as `APIConnectionError` (which the retry predicate treats as retryable via `cause`). `httpx2.TimeoutException` is already a subclass of `TransportError`, so the existing timeout handler above it still wins on ordering, and the `isinstance(err, AnthropicError)` guard is dead code now, so it's gone.

Added `test_sync_stream_does_not_mislabel_a_parse_error_as_a_connection_error`, which injects malformed JSON into an in-stream event — confirmed it fails against the pre-narrowing version (raises `APIConnectionError`) and passes now. Full suite is 26/26.

Re: #1920 and #1921 — didn't know about either when I opened this, sorry for the overlap. #1920 lands the same narrowing already, so happy to close this in favor of that one if a maintainer prefers it; flagging rather than deciding unilaterally since #1921 also touches these same two `try` blocks for the in-stream error-event type mapping, and whichever lands first affects the other two.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transport errors while consuming a stream escape as raw httpx exceptions instead of APITimeoutError / APIConnectionError

2 participants