fix(streaming): wrap mid-stream transport errors as APITimeoutError/APIConnectionError - #1922
fix(streaming): wrap mid-stream transport errors as APITimeoutError/APIConnectionError#1922adhavan18 wants to merge 2 commits into
Conversation
…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.
tonydzi
left a comment
There was a problem hiding this comment.
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 errhttpx2.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.
|
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. |
Description
_base_clientwraps the initial send —httpx.TimeoutExceptionbecomesAPITimeoutError, other transport errors becomeAPIConnectionError, and both go through the retry loop. Once the response is streaming,Stream.__stream__/AsyncStream.__stream__in_streaming.pyiterated the response with no exception handling at all, so a read timeout or a dropped connection mid-stream surfaced as the rawhttpx.ReadTimeout/httpx.RemoteProtocolError, not ananthropic.APIError.That means
except anthropic.APIErroraround a streaming call misses the most common streaming failure, andmax_retriesis never consulted for it.The fix wraps the iteration loop in both
__stream__methods with the same pattern_base_client.pyalready uses for the initial request:httpx2.TimeoutException→APITimeoutError, an already-raisedAnthropicError(the in-streamerror-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#3818Closes #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)isFalse; after, it raisesAPITimeoutErrorandisinstanceisTrue.Added
test_sync_stream_wraps_mid_stream_transport_errorandtest_async_stream_wraps_mid_stream_transport_errortotests/test_streaming.py— both confirmed to fail on the unfixed code (rawhttpx2.ReadTimeoutescapes) and pass with the fix. Fulltests/test_streaming.pysuite (25 tests) passes.