From f89342b0c8759869ee47b1d252210cebb970137c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 14:30:50 +0300 Subject: [PATCH] fix: map InvalidURL in build_request; clear TypeError for sync-stream middleware responses build_request on both clients now raises TransportError for an invalid URL, with the httpx2.InvalidURL as __cause__, like every other entry point. When AsyncClient middleware returns an unread response with a sync stream, send() and send_with_response() raise a TypeError that names the problem instead of the RuntimeError from inside httpx2. Client gets the mirror check for an async-stream response, which used to surface as a DecodeError. Closes #175 Closes #176 --- docs/middleware.md | 2 + src/httpware/client.py | 63 +++++++++++++++++++++++--------- tests/test_client_error_paths.py | 57 +++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 17 deletions(-) diff --git a/docs/middleware.md b/docs/middleware.md index 137b88f..d6710f6 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -191,6 +191,8 @@ with Client(base_url="https://api.example.com", middleware=[add_request_id]) as Sync and async middleware don't mix: pass `Middleware` to `Client` and `AsyncMiddleware` to `AsyncClient`. `Client` raises `TypeError` when you build it with async middleware, such as `AsyncRetry` or a function wrapped by `@async_before_request`. +The same split applies to a response your middleware builds itself. If its body comes from an iterator, use an async iterator on `AsyncClient` and a sync one on `Client`. With the wrong kind, a call with `response_model` raises `TypeError`. + ## See also - [Resilience](resilience.md): the built-in middleware and the order to compose them in. diff --git a/src/httpware/client.py b/src/httpware/client.py index abb76a9..0b0a321 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -400,18 +400,41 @@ def _request_kwargs( def _build_request( client: httpx2.Client | httpx2.AsyncClient, method: str, url: httpx2.URL | str, kwargs: dict[str, typing.Any] ) -> httpx2.Request: - """Build via `client`, keeping the URL's own query bytes ahead of any params, which httpx2 would replace.""" - if (isinstance(url, str) and "?" not in url) or (kwargs.get("params") is None and not client.params): - return client.build_request(method, url, **kwargs) - parsed = httpx2.URL(url) - if not parsed.query: - return client.build_request(method, url, **kwargs) - request = client.build_request(method, parsed.copy_with(query=None), **kwargs) + """Build via `client`, keeping the URL's own query bytes ahead of any params; map InvalidURL to TransportError.""" + try: + if (isinstance(url, str) and "?" not in url) or (kwargs.get("params") is None and not client.params): + return client.build_request(method, url, **kwargs) + parsed = httpx2.URL(url) + if not parsed.query: + return client.build_request(method, url, **kwargs) + request = client.build_request(method, parsed.copy_with(query=None), **kwargs) + except httpx2.InvalidURL as exc: + raise map_httpx2_exception(exc) from exc built_query = request.url.query request.url = request.url.copy_with(query=parsed.query + b"&" + built_query if built_query else parsed.query) return request +def _reject_sync_stream(response: httpx2.Response) -> None: + """Raise TypeError unless an unread response from AsyncClient middleware has an async stream.""" + if not isinstance(response.stream, httpx2.AsyncByteStream): + msg = ( + "AsyncClient middleware returned a response with a sync stream;" + " AsyncClient middleware must return async-streamed responses" + ) + raise TypeError(msg) + + +def _reject_async_stream(response: httpx2.Response) -> None: + """Raise TypeError unless an unread response from Client middleware has a sync stream.""" + if not isinstance(response.stream, httpx2.SyncByteStream): + msg = ( + "Client middleware returned a response with an async stream;" + " Client middleware must return sync-streamed responses" + ) + raise TypeError(msg) + + class AsyncClient: """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware.""" @@ -496,6 +519,7 @@ async def _send(self, request: httpx2.Request, response_model: type[T] | None) - bound = self._decoder_resolver.resolve(response_model) response = await self._dispatch(request) if not response.is_closed: + _reject_sync_stream(response) await response.aread() return bound.decode(response) @@ -521,11 +545,15 @@ async def _send_with_response(self, request: httpx2.Request, response_model: typ bound = self._decoder_resolver.resolve(response_model) response = await self._dispatch(request) if not response.is_closed: + _reject_sync_stream(response) await response.aread() return response, bound.decode(response) def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: - """Delegate request construction to the wrapped httpx2.AsyncClient, keeping the URL's own query.""" + """Delegate request construction to the wrapped httpx2.AsyncClient, keeping the URL's own query. + + An invalid URL raises TransportError, with the httpx2.InvalidURL as `__cause__`. + """ return _build_request(self._httpx2_client, method, url, kwargs) def _prepare_request( @@ -537,10 +565,7 @@ def _prepare_request( defaults: Mapping[str, object], ) -> httpx2.Request: kwargs = _request_kwargs(self, caller, options, defaults) - try: - request = _build_request(self._httpx2_client, method, url, kwargs) - except httpx2.InvalidURL as exc: - raise map_httpx2_exception(exc) from exc + request = _build_request(self._httpx2_client, method, url, kwargs) if ("content" in kwargs or "data" in kwargs or "files" in kwargs) and ( _is_streaming_body_async(kwargs.get("content")) or _is_streaming_body_async(kwargs.get("data")) @@ -1035,6 +1060,8 @@ def _send(self, request: httpx2.Request, response_model: type[T] | None) -> http bound = self._decoder_resolver.resolve(response_model) response = self._dispatch(request) + if not response.is_closed: + _reject_async_stream(response) return bound.decode(response) def send_with_response( @@ -1058,10 +1085,15 @@ def send_with_response( def _send_with_response(self, request: httpx2.Request, response_model: type[T]) -> tuple[httpx2.Response, T]: bound = self._decoder_resolver.resolve(response_model) response = self._dispatch(request) + if not response.is_closed: + _reject_async_stream(response) return response, bound.decode(response) def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: - """Delegate request construction to the wrapped httpx2.Client, keeping the URL's own query.""" + """Delegate request construction to the wrapped httpx2.Client, keeping the URL's own query. + + An invalid URL raises TransportError, with the httpx2.InvalidURL as `__cause__`. + """ return _build_request(self._httpx2_client, method, url, kwargs) def _prepare_request( @@ -1073,10 +1105,7 @@ def _prepare_request( defaults: Mapping[str, object], ) -> httpx2.Request: kwargs = _request_kwargs(self, caller, options, defaults) - try: - request = _build_request(self._httpx2_client, method, url, kwargs) - except httpx2.InvalidURL as exc: - raise map_httpx2_exception(exc) from exc + request = _build_request(self._httpx2_client, method, url, kwargs) if ("content" in kwargs or "data" in kwargs or "files" in kwargs) and ( _is_streaming_body_sync(kwargs.get("content")) or _is_streaming_body_sync(kwargs.get("data")) diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py index 5208fff..d98ec12 100644 --- a/tests/test_client_error_paths.py +++ b/tests/test_client_error_paths.py @@ -402,3 +402,60 @@ async def test_async_verb_decodes_unread_async_stream_from_middleware() -> None: assert await client.get("https://example.test/x", response_model=dict) == {"id": 1} _, decoded = await client.get_with_response("https://example.test/x", response_model=dict) assert decoded == {"id": 1} + + +async def test_async_build_request_invalid_url_maps_to_transport_error() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TransportError, match="non-printable") as info: + client.build_request("GET", _INVALID_URL) + assert not isinstance(info.value, NetworkError) + assert isinstance(info.value.__cause__, httpx2.InvalidURL) + + +def test_sync_build_request_invalid_url_maps_to_transport_error() -> None: + client = Client(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TransportError, match="non-printable") as info: + client.build_request("GET", _INVALID_URL) + assert not isinstance(info.value, NetworkError) + assert isinstance(info.value.__cause__, httpx2.InvalidURL) + + +def _sync_json_chunks() -> Iterator[bytes]: + yield b'{"id": 1}' + + +class _UnreadSyncStreamResponse: + async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002, ARG002 + return httpx2.Response(HTTPStatus.OK, content=_sync_json_chunks(), request=request) + + +class _SyncMiddlewareAsyncStreamResponse: + def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, ARG002 + return httpx2.Response(HTTPStatus.OK, content=_async_json_chunks(), request=request) + + +async def test_async_send_rejects_sync_stream_from_middleware() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok), middleware=[_UnreadSyncStreamResponse()]) + with pytest.raises(TypeError, match="sync stream"): + await client.get("https://example.test/x", response_model=dict) + with pytest.raises(TypeError, match="AsyncClient middleware must return"): + await client.get_with_response("https://example.test/x", response_model=dict) + + +def test_sync_send_rejects_async_stream_from_middleware() -> None: + client = Client(transport=httpx2.MockTransport(_ok), middleware=[_SyncMiddlewareAsyncStreamResponse()]) + with pytest.raises(TypeError, match="async stream"): + client.get("https://example.test/x", response_model=dict) + with pytest.raises(TypeError, match="Client middleware must return"): + client.get_with_response("https://example.test/x", response_model=dict) + + +def test_sync_send_decodes_unread_sync_stream_from_middleware() -> None: + class _UnreadSync: + def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, ARG002 + return httpx2.Response(HTTPStatus.OK, content=_sync_json_chunks(), request=request) + + client = Client(transport=httpx2.MockTransport(_ok), middleware=[_UnreadSync()]) + assert client.get("https://example.test/x", response_model=dict) == {"id": 1} + _, decoded = client.get_with_response("https://example.test/x", response_model=dict) + assert decoded == {"id": 1}