Skip to content

fix!: consistent client error paths (timeout=None, RemoteProtocolError, invalid URL, closed client, streaming bodies, async middleware) - #169

Merged
lesnik512 merged 8 commits into
mainfrom
fix/client-error-paths
Oct 17, 2026
Merged

lesnik512 merged 8 commits into
mainfrom
fix/client-error-paths

Conversation

@lesnik512

Copy link
Copy Markdown
Member

What changed

Seven fixes to Client and AsyncClient, each one test-first. Every change is mirrored in both clients except the last two, which only apply to one of them.

  1. timeout=None now means no timeout (breaking). _select_httpx2_options treated every None option as "not passed", so Client(timeout=None) silently kept httpx2's 5 second default. An explicit timeout=None is now forwarded to the owned httpx2 client. The other options still treat None as unset.
  2. httpx2.RemoteProtocolError maps to NetworkError. It used to become a bare TransportError, so neither Retry nor the circuit breaker treated it as transient. The most common cause is a server dropping a keep-alive connection mid-response, which is exactly the case retry exists for.
  3. An invalid URL raises TransportError from every method. stream() already mapped httpx2.InvalidURL, but get(), post(), request(), the *_with_response methods and the rest raised it raw, because the request is built outside the mapper. _prepare_request now maps it. build_request() still raises httpx2.InvalidURL, since it returns an httpx2 object and sends nothing.
  4. stream() on a closed client raises TransportError. It raised httpx2's raw RuntimeError, while get() raised TransportError. The closed-client check moved out of each _terminal and into the shared _httpx2_exception_mapper / _httpx2_exception_mapper_sync, which now take the httpx2 client, so the terminal and stream() share it. A RuntimeError on an open client still propagates unchanged.
  5. Retry refuses streaming bodies on requests passed to send(). Only the verb methods set STREAMING_BODY_MARKER, so send(client.build_request(..., content=gen)) and send(httpx2.Request(..., content=gen)) were retried with a consumed generator. With a real transport that sends an empty or truncated body; MockTransport hides it because request.read() caches the body. send() and send_with_response() now call _mark_streaming_body, which sets the marker when request.stream is not an httpx2.ByteStream and the content type is not multipart/form-data. The verb methods keep their existing argument-based check and go through new private _send / _send_with_response, so they are not checked twice.
  6. Client rejects async middleware at construction. Passing AsyncRetry() or an @async_before_request function to Client used to fail later in confusing ways. Client.__init__ now raises TypeError when a middleware, or its class's __call__, is a coroutine function. AsyncClient gets no reverse check.
  7. AsyncClient reads an unread response before decoding it. A middleware that returns an unread response with an async-only stream, such as httpx2.Response(200, content=<async generator>), reached the decoder unread and failed with DecodeError. send() and send_with_response() (and the verbs, through _send / _send_with_response) now await response.aread() first when response.is_closed is false. Every buffered response is already closed, so the normal path only pays for the property read. The sync Client is unchanged here; the decoder-side sync read lands in a separate PR.

Docs: docs/index.md (timeout option), docs/errors.md (new table mapping httpx2 exceptions to httpware ones, plus invalid URL and closed client), docs/resilience.md (streaming-body refusal for send()), docs/middleware.md (the TypeError).

Release notes (0.22.0)

Breaking: Client(timeout=None) and AsyncClient(timeout=None) now disable the timeout instead of keeping httpx2's 5 second default. To keep the default, leave timeout out. Passing timeout=None together with httpx2_client= now raises the same TypeError as any other option combined with a caller-owned client.

Other behaviour changes:

  • httpx2.RemoteProtocolError raises NetworkError (still a TransportError), so Retry/AsyncRetry retry it and circuit breakers count it.
  • An invalid URL passed to a verb method or request() raises TransportError instead of httpx2.InvalidURL.
  • stream() on a closed client raises TransportError instead of RuntimeError.
  • Requests passed to send() / send_with_response() with a generator, iterator or file body are no longer retried.
  • Client(middleware=[...]) raises TypeError for async middleware.
  • AsyncClient decodes an unread async-stream response returned by middleware.

Judgement calls for review

  • timeout=None with httpx2_client= raises the conflict TypeError. The caller-owned client cannot honour it, so I treated it as a set option. The alternative is to ignore it silently, which would bring back the bug from item 1.
  • Item 5 puts the marker in send() rather than in _build_request. Applying the stream-based rule in _build_request would also mark post(content=[b"a", b"b"]): httpx2 wraps a list in the same iterator stream as a generator, and tests/test_retry_sync.py::test_client_post_with_list_content_does_not_mark_extensions requires that a list stays retryable. The verb methods keep the argument-based check, which can tell a list from a generator. Known limitation: a list body sent through build_request() + send() is marked and not retried. docs/resilience.md says so.
  • With item 5, _is_streaming_body_async / _is_streaming_body_sync in _internal/status.py are still used by _prepare_request, so nothing became dead code.
  • Item 3 leaves build_request() raising httpx2.InvalidURL.
  • Item 4 maps a RuntimeError raised inside a stream() block to TransportError only when the httpx2 client is closed at that point, which matches what _terminal already did.
  • Item 7: a sync-only unread stream returned by middleware in AsyncClient now fails with httpx2's RuntimeError from aread() instead of DecodeError. That middleware was already broken; flagging it because the exception type changes.

Tests

New tests/test_client_error_paths.py (48 tests, both clients) and updated tests/test_client_options.py. Each test was run red before its fix:

  • timeout: test_explicit_timeout_none_is_forwarded_as_no_timeout failed with Expected: Client(timeout=None) / Actual: Client(); test_unset_options_are_not_forwarded no longer passes timeout=None.
  • RemoteProtocolError: test_sync_remote_protocol_error_maps_to_network_error failed with httpware.errors.TransportError: peer closed connection ....
  • invalid URL: test_sync_invalid_url_on_verb_maps_to_transport_error[get] failed with httpx2.InvalidURL: Invalid non-printable ASCII character in URL.
  • closed client: test_sync_stream_on_closed_client_raises_transport_error failed with RuntimeError: Cannot send a request, as the client has been closed.
  • streaming marker: test_sync_send_of_raw_streaming_request_refuses_retry failed with assert 3 == 1 (three attempts).
  • async middleware: test_sync_client_rejects_async_middleware[AsyncRetry] failed with DID NOT RAISE TypeError.
  • aread: test_async_send_decodes_unread_async_stream_from_middleware failed with DecodeError: ... without having called read().

just lint, just lint-ci and just test pass: 1061 tests. Every commit passes the suite on its own.

Performance

End-to-end timing of a sync get() against MockTransport is about 47 to 60 µs on this machine, and the noise between interleaved base/head runs was over 10 µs, so it cannot show a 0.1 µs change. No regression was visible. Per added operation:

  • verb path (get() etc.): _send replaces the send call at the same depth; the InvalidURL try/except costs nothing unless it raises; the mapper takes one extra argument (enter/exit cost the same within noise); the RemoteProtocolError check runs only on the error path.
  • send() with a raw request: _mark_streaming_body is about 80 ns (108 ns including the timing lambda).
  • AsyncClient decode path: the response.is_closed guard is about 30 ns. An unguarded await response.aread() on an already-read response cost about 144 ns, which is why the guard is there.
  • Client.__init__: the coroutine check runs once per middleware at construction only.

@lesnik512
lesnik512 merged commit a0c1a87 into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the fix/client-error-paths branch October 17, 2026 09:12
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.

1 participant