From 972f802d8d3c9384a0d49e0e4f81b5d781f1a609 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:35:47 +0300 Subject: [PATCH 1/8] fix!: forward an explicit timeout=None as no timeout --- docs/index.md | 2 +- src/httpware/client.py | 8 ++++++-- tests/test_client_options.py | 28 +++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 4f5b742..427867b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -124,7 +124,7 @@ async def main() -> None: `base_url` must not contain a query string; a client built with one raises `ValueError`. Put query parameters shared by every request in `params=` instead. -The other keywords of `httpx2.AsyncClient` and `httpx2.Client` (`verify`, `proxy`, `http2`, `transport`, `follow_redirects`, ...) are passed through to the `httpx2` client that httpware builds and closes. Two are refused. `cert` is deprecated by `httpx2` in favour of an `ssl.SSLContext` passed as `verify`. `event_hooks` run below the middleware chain, so use [middleware](middleware.md) instead. +The other keywords of `httpx2.AsyncClient` and `httpx2.Client` (`verify`, `proxy`, `http2`, `transport`, `follow_redirects`, ...) are passed through to the `httpx2` client that httpware builds and closes. An option set to `None` counts as not passed, so `httpx2` keeps its default, with one exception: `timeout=None` turns the timeout off. Leave `timeout` out to keep the `httpx2` default of 5 seconds. Two options are refused. `cert` is deprecated by `httpx2` in favour of an `ssl.SSLContext` passed as `verify`. `event_hooks` run below the middleware chain, so use [middleware](middleware.md) instead. ```python import ssl diff --git a/src/httpware/client.py b/src/httpware/client.py index 65ff7d3..20d5bda 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -106,7 +106,9 @@ class _ClientOptions(_ClientOptionsBase, total=False, closed=True): mounts: Mapping[str, httpx2.BaseTransport | None] | None -def _is_unset(value: object) -> bool: +def _is_unset(name: str, value: object) -> bool: + if name == "timeout": + return False return value is None or (isinstance(value, str) and not value) @@ -119,6 +121,8 @@ def _select_httpx2_options( ) -> dict[str, typing.Any]: """Return the options to forward to the owned httpx2 client, dropping unset ones. + `None` and `""` mean unset, except for `timeout`, where an explicit `None` means no timeout. + Raise TypeError for unsupported options or options combined with `httpx2_client`. """ unsupported = sorted(options.keys() - supported) @@ -128,7 +132,7 @@ def _select_httpx2_options( ) msg = f"{owner}() got unexpected keyword arguments {unsupported}.{hints}" raise TypeError(msg) - forwarded = {name: value for name, value in options.items() if not _is_unset(value)} + forwarded = {name: value for name, value in options.items() if not _is_unset(name, value)} if httpx2_client is not None and forwarded: raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE.format(names=sorted(forwarded))) return forwarded diff --git a/tests/test_client_options.py b/tests/test_client_options.py index 6f643a7..2613c74 100644 --- a/tests/test_client_options.py +++ b/tests/test_client_options.py @@ -86,11 +86,37 @@ def test_unset_options_are_not_forwarded( httpx2_name: str, ) -> None: with patch.object(httpx2, httpx2_name) as httpx2_cls: - client_cls(base_url="", headers=None, timeout=None, proxy=None, transport=None) + client_cls(base_url="", headers=None, proxy=None, transport=None) httpx2_cls.assert_called_once_with() +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_explicit_timeout_none_is_forwarded_as_no_timeout(client_cls: type, httpx2_name: str) -> None: + with patch.object(httpx2, httpx2_name) as httpx2_cls: + client_cls(timeout=None) + + httpx2_cls.assert_called_once_with(timeout=None) + + +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_explicit_timeout_none_disables_the_owned_client_timeout(client_cls: type, httpx2_name: str) -> None: + client = client_cls(timeout=None) + + assert client._httpx2_client.timeout == httpx2.Timeout(None) # noqa: SLF001 + assert isinstance(client._httpx2_client, getattr(httpx2, httpx2_name)) # noqa: SLF001 + + +@pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) +def test_explicit_timeout_none_with_caller_owned_httpx2_client_is_typeerror( + client_cls: type, + httpx2_name: str, +) -> None: + caller = getattr(httpx2, httpx2_name)(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TypeError, match=r"httpx2_client.*timeout"): + client_cls(httpx2_client=caller, timeout=None) + + @pytest.mark.parametrize("key", _KEYS) @pytest.mark.parametrize(("client_cls", "httpx2_name"), _WORLDS) def test_option_with_caller_owned_httpx2_client_is_typeerror( From b8acf1ed3f1e61fbd64ed66c5c90b71136a489cc Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:37:58 +0300 Subject: [PATCH 2/8] fix: map httpx2 RemoteProtocolError to NetworkError --- docs/errors.md | 13 ++ src/httpware/_internal/exception_mapping.py | 6 +- tests/test_client_error_paths.py | 158 ++++++++++++++++++++ 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tests/test_client_error_paths.py diff --git a/docs/errors.md b/docs/errors.md index cb7778b..580fd46 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -31,6 +31,19 @@ ClientError (catch-all for anything httpware raises) └── ResponseTooLargeError (response body exceeds max_response_body_bytes; status-agnostic) ``` +## Mapping `httpx2` exceptions + +An exception from `httpx2` is re-raised as an httpware one, with the original on `__cause__`: + +| `httpx2` exception | httpware exception | +|---|---| +| `TimeoutException` and its subclasses | `TimeoutError` | +| `NetworkError` (connect, read, write and close errors) | `NetworkError` | +| `RemoteProtocolError`, such as a server dropping a keep-alive connection mid-response | `NetworkError` | +| any other `HTTPError`, `InvalidURL`, `CookieConflict` | `TransportError` | + +`NetworkError` and `TimeoutError` are the transient ones: `AsyncRetry` and `Retry` retry them, and the circuit breaker counts them. + ## Status-to-exception mapping | Status | Exception class | diff --git a/src/httpware/_internal/exception_mapping.py b/src/httpware/_internal/exception_mapping.py index 33be681..9fb57fc 100644 --- a/src/httpware/_internal/exception_mapping.py +++ b/src/httpware/_internal/exception_mapping.py @@ -2,8 +2,8 @@ map_httpx2_exception is a pure function used by both Client._terminal and AsyncClient._terminal, and by both stream() methods. Clause ordering: -TimeoutException -> InvalidURL/CookieConflict -> NetworkError -> HTTPError -(subclass before parent so the right type wins). The two context managers +TimeoutException -> InvalidURL/CookieConflict -> NetworkError/RemoteProtocolError +-> HTTPError (subclass before parent so the right type wins). The two context managers below wrap it for use as `with`/`async with` blocks around the httpx2 call. """ @@ -25,7 +25,7 @@ def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | Tr return TimeoutError(str(exc)) if isinstance(exc, (httpx2.InvalidURL, httpx2.CookieConflict)): return TransportError(str(exc)) - if isinstance(exc, httpx2.NetworkError): + if isinstance(exc, (httpx2.NetworkError, httpx2.RemoteProtocolError)): return NetworkError(str(exc)) if isinstance(exc, httpx2.HTTPError): return TransportError(str(exc)) diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py new file mode 100644 index 0000000..d95e478 --- /dev/null +++ b/tests/test_client_error_paths.py @@ -0,0 +1,158 @@ +"""Error paths that must behave the same on every entry point of both clients.""" + +import typing +from collections.abc import AsyncIterator, Iterator +from http import HTTPStatus + +import httpx2 +import pytest + +from httpware import ( + AsyncClient, + AsyncNext, + AsyncRetry, + Client, + NetworkError, + Next, + Retry, + ServiceUnavailableError, + TransportError, + after_response, + async_after_response, + async_before_request, + async_on_error, + before_request, +) +from httpware._internal.status import STREAMING_BODY_MARKER + + +_INVALID_URL = "https://example.test/\x00" +_REFUSAL_NOTE = "httpware: not retrying because the request body is a stream that cannot replay across attempts" + + +def _ok(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, request=request) + + +def _remote_protocol_error(request: httpx2.Request) -> httpx2.Response: + msg = "peer closed connection without sending complete message body" + raise httpx2.RemoteProtocolError(msg, request=request) + + +async def _no_sleep(_: float) -> None: + return None + + +def _no_sleep_sync(_: float) -> None: + return None + + +def _sync_chunks() -> Iterator[bytes]: + yield b"chunk" + + +async def _async_chunks() -> AsyncIterator[bytes]: + yield b"chunk" + + +class _Counting503: + def __init__(self) -> None: + self.calls = 0 + + def __call__(self, request: httpx2.Request) -> httpx2.Response: + self.calls += 1 + request.read() + return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request) + + +class _AsyncCounting503: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, request: httpx2.Request) -> httpx2.Response: + self.calls += 1 + await request.aread() + return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request) + + +async def test_async_remote_protocol_error_maps_to_network_error() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_remote_protocol_error)) + with pytest.raises(NetworkError, match="peer closed"): + await client.get("https://example.test/x") + + +def test_sync_remote_protocol_error_maps_to_network_error() -> None: + client = Client(transport=httpx2.MockTransport(_remote_protocol_error)) + with pytest.raises(NetworkError, match="peer closed"): + client.get("https://example.test/x") + + +async def test_async_retry_retries_remote_protocol_error() -> None: + calls: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(request) + if len(calls) == 1: + return _remote_protocol_error(request) + return _ok(request) + + client = AsyncClient(transport=httpx2.MockTransport(handler), middleware=[AsyncRetry(_sleep=_no_sleep)]) + response = await client.get("https://example.test/x") + assert response.status_code == HTTPStatus.OK + assert len(calls) == 2 # noqa: PLR2004 + + +def test_sync_retry_retries_remote_protocol_error() -> None: + calls: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(request) + if len(calls) == 1: + return _remote_protocol_error(request) + return _ok(request) + + client = Client(transport=httpx2.MockTransport(handler), middleware=[Retry(_sleep=_no_sleep_sync)]) + response = client.get("https://example.test/x") + assert response.status_code == HTTPStatus.OK + assert len(calls) == 2 # noqa: PLR2004 + + +def _async_retry_client(handler: typing.Any) -> AsyncClient: # noqa: ANN401 + return AsyncClient( + transport=httpx2.MockTransport(handler), + middleware=[AsyncRetry(_sleep=_no_sleep, retry_methods=frozenset({"POST"}))], + ) + + +def _sync_retry_client(handler: typing.Any) -> Client: # noqa: ANN401 + return Client( + transport=httpx2.MockTransport(handler), + middleware=[Retry(_sleep=_no_sleep_sync, retry_methods=frozenset({"POST"}))], + ) + + +class _AsyncPassThrough: + async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002 + return await next(request) + + +async def _async_function_middleware(request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002 + return await next(request) + + +async def _async_identity(request: httpx2.Request) -> httpx2.Request: + return request + + +async def _async_response_identity(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: # noqa: ARG001 + return response + + +async def _async_swallow(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: # noqa: ARG001 + return None + + +def _sync_function_middleware(request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + return next(request) + + From b929565472f7ba736b7492ea48d5976cdc2a0a6c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:38:30 +0300 Subject: [PATCH 3/8] fix: map an invalid URL to TransportError when building the request --- docs/errors.md | 2 +- src/httpware/client.py | 11 +++++++++-- tests/test_client_error_paths.py | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 580fd46..ba3f1ca 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -42,7 +42,7 @@ An exception from `httpx2` is re-raised as an httpware one, with the original on | `RemoteProtocolError`, such as a server dropping a keep-alive connection mid-response | `NetworkError` | | any other `HTTPError`, `InvalidURL`, `CookieConflict` | `TransportError` | -`NetworkError` and `TimeoutError` are the transient ones: `AsyncRetry` and `Retry` retry them, and the circuit breaker counts them. +`NetworkError` and `TimeoutError` are the transient ones: `AsyncRetry` and `Retry` retry them, and the circuit breaker counts them. An invalid URL raises `TransportError` from every method, whether `httpx2` rejects it while the request is built or while it is sent. ## Status-to-exception mapping diff --git a/src/httpware/client.py b/src/httpware/client.py index 20d5bda..4d7abf9 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -14,6 +14,7 @@ from httpware._internal.exception_mapping import ( _httpx2_exception_mapper, _httpx2_exception_mapper_sync, + map_httpx2_exception, ) from httpware._internal.status import ( STREAMING_BODY_MARKER, @@ -489,7 +490,10 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; data=data, files=files, ) - request = _build_request(self._httpx2_client, method, url, kwargs) + try: + request = _build_request(self._httpx2_client, method, url, kwargs) + except httpx2.InvalidURL as exc: + raise map_httpx2_exception(exc) from exc if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files): request.extensions[STREAMING_BODY_MARKER] = True return request @@ -1448,7 +1452,10 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; data=data, files=files, ) - request = _build_request(self._httpx2_client, method, url, kwargs) + try: + request = _build_request(self._httpx2_client, method, url, kwargs) + except httpx2.InvalidURL as exc: + raise map_httpx2_exception(exc) from exc if _is_streaming_body_sync(content) or _is_streaming_body_sync(data) or _is_streaming_body_sync(files): request.extensions[STREAMING_BODY_MARKER] = True return request diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py index d95e478..8a11d69 100644 --- a/tests/test_client_error_paths.py +++ b/tests/test_client_error_paths.py @@ -117,6 +117,40 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert len(calls) == 2 # noqa: PLR2004 +@pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete", "head", "options"]) +async def test_async_invalid_url_on_verb_maps_to_transport_error(method: str) -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TransportError, match="non-printable") as info: + await getattr(client, method)(_INVALID_URL) + assert not isinstance(info.value, NetworkError) + assert isinstance(info.value.__cause__, httpx2.InvalidURL) + + +@pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete", "head", "options"]) +def test_sync_invalid_url_on_verb_maps_to_transport_error(method: str) -> None: + client = Client(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TransportError, match="non-printable") as info: + getattr(client, method)(_INVALID_URL) + assert not isinstance(info.value, NetworkError) + assert isinstance(info.value.__cause__, httpx2.InvalidURL) + + +async def test_async_invalid_url_on_request_and_with_response_maps_to_transport_error() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TransportError, match="non-printable"): + await client.request("GET", _INVALID_URL) + with pytest.raises(TransportError, match="non-printable"): + await client.get_with_response(_INVALID_URL, response_model=dict) + + +def test_sync_invalid_url_on_request_and_with_response_maps_to_transport_error() -> None: + client = Client(transport=httpx2.MockTransport(_ok)) + with pytest.raises(TransportError, match="non-printable"): + client.request("GET", _INVALID_URL) + with pytest.raises(TransportError, match="non-printable"): + client.get_with_response(_INVALID_URL, response_model=dict) + + def _async_retry_client(handler: typing.Any) -> AsyncClient: # noqa: ANN401 return AsyncClient( transport=httpx2.MockTransport(handler), From 68cca5c25902fe118169bc27981d88ee4227c525 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:39:01 +0300 Subject: [PATCH 4/8] fix: raise TransportError from stream() on a closed client --- docs/errors.md | 2 +- src/httpware/_internal/exception_mapping.py | 22 +++++++++--- src/httpware/client.py | 39 ++++++++------------- tests/test_client_error_paths.py | 39 +++++++++++++++++---- 4 files changed, 64 insertions(+), 38 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index ba3f1ca..b1a5132 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -42,7 +42,7 @@ An exception from `httpx2` is re-raised as an httpware one, with the original on | `RemoteProtocolError`, such as a server dropping a keep-alive connection mid-response | `NetworkError` | | any other `HTTPError`, `InvalidURL`, `CookieConflict` | `TransportError` | -`NetworkError` and `TimeoutError` are the transient ones: `AsyncRetry` and `Retry` retry them, and the circuit breaker counts them. An invalid URL raises `TransportError` from every method, whether `httpx2` rejects it while the request is built or while it is sent. +`NetworkError` and `TimeoutError` are the transient ones: `AsyncRetry` and `Retry` retry them, and the circuit breaker counts them. An invalid URL raises `TransportError` from every method, whether `httpx2` rejects it while the request is built or while it is sent. So does a call on a client that has been closed, `stream()` included. ## Status-to-exception mapping diff --git a/src/httpware/_internal/exception_mapping.py b/src/httpware/_internal/exception_mapping.py index 9fb57fc..b6f8625 100644 --- a/src/httpware/_internal/exception_mapping.py +++ b/src/httpware/_internal/exception_mapping.py @@ -4,7 +4,8 @@ AsyncClient._terminal, and by both stream() methods. Clause ordering: TimeoutException -> InvalidURL/CookieConflict -> NetworkError/RemoteProtocolError -> HTTPError (subclass before parent so the right type wins). The two context managers -below wrap it for use as `with`/`async with` blocks around the httpx2 call. +below wrap it for use as `with`/`async with` blocks around the httpx2 call, +and also turn the RuntimeError httpx2 raises on a closed client into TransportError. """ import contextlib @@ -33,22 +34,33 @@ def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | Tr @contextlib.asynccontextmanager -async def _httpx2_exception_mapper() -> AsyncGenerator[None]: - """Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream().""" +async def _httpx2_exception_mapper(client: httpx2.AsyncClient) -> AsyncGenerator[None]: + """Map httpx2 exceptions, and use of a closed `client`, to httpware exceptions. + + Shared by AsyncClient._terminal and stream(). + """ try: yield except httpx2.HTTPError as exc: raise map_httpx2_exception(exc) from exc except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: raise map_httpx2_exception(exc) from exc + except RuntimeError as exc: + if client.is_closed: + raise TransportError(str(exc)) from exc + raise @contextlib.contextmanager -def _httpx2_exception_mapper_sync() -> Generator[None]: - """Map httpx2 exceptions to httpware exceptions. Sync sibling of _httpx2_exception_mapper.""" +def _httpx2_exception_mapper_sync(client: httpx2.Client) -> Generator[None]: + """Sync sibling of _httpx2_exception_mapper.""" try: yield except httpx2.HTTPError as exc: raise map_httpx2_exception(exc) from exc except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: raise map_httpx2_exception(exc) from exc + except RuntimeError as exc: + if client.is_closed: + raise TransportError(str(exc)) from exc + raise diff --git a/src/httpware/client.py b/src/httpware/client.py index 4d7abf9..393fa4f 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -24,7 +24,6 @@ ) from httpware.decoders import ResponseDecoder from httpware.decoders._resolver import _DecoderResolver -from httpware.errors import TransportError from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next from httpware.middleware.chain import compose, compose_async @@ -407,17 +406,12 @@ def __init__( async def _terminal(self, request: httpx2.Request) -> httpx2.Response: cap = self._max_response_body_bytes - try: - async with _httpx2_exception_mapper(): - if cap is None: - response = await self._httpx2_client.send(request) - else: - streaming = await _send_capped_async(self._httpx2_client, request, cap) - response = await _read_capped_and_close_async(streaming, cap) - except RuntimeError as exc: - if self._httpx2_client.is_closed: - raise TransportError(str(exc)) from exc - raise + async with _httpx2_exception_mapper(self._httpx2_client): + if cap is None: + response = await self._httpx2_client.send(request) + else: + streaming = await _send_capped_async(self._httpx2_client, request, cap) + response = await _read_capped_and_close_async(streaming, cap) _raise_on_status_error(response) return response @@ -1267,7 +1261,7 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa if cap is None else _stream_capped_async(self._httpx2_client, method, url, kwargs, cap) ) - async with _httpx2_exception_mapper(), opened as response: + async with _httpx2_exception_mapper(self._httpx2_client), opened as response: if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx if cap is None: await response.aread() # pre-read body so exc.response.content works @@ -1345,17 +1339,12 @@ def __init__( def _terminal(self, request: httpx2.Request) -> httpx2.Response: cap = self._max_response_body_bytes - try: - with _httpx2_exception_mapper_sync(): - if cap is None: - response = self._httpx2_client.send(request) - else: - streaming = _send_capped(self._httpx2_client, request, cap) - response = _read_capped_and_close(streaming, cap) - except RuntimeError as exc: - if self._httpx2_client.is_closed: - raise TransportError(str(exc)) from exc - raise + with _httpx2_exception_mapper_sync(self._httpx2_client): + if cap is None: + response = self._httpx2_client.send(request) + else: + streaming = _send_capped(self._httpx2_client, request, cap) + response = _read_capped_and_close(streaming, cap) _raise_on_status_error(response) return response @@ -2226,7 +2215,7 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo if cap is None else _stream_capped(self._httpx2_client, method, url, kwargs, cap) ) - with _httpx2_exception_mapper_sync(), opened as response: + with _httpx2_exception_mapper_sync(self._httpx2_client), opened as response: if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx if cap is None: response.read() # pre-read body so exc.response.content works diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py index 8a11d69..7f8dc62 100644 --- a/tests/test_client_error_paths.py +++ b/tests/test_client_error_paths.py @@ -17,11 +17,6 @@ Retry, ServiceUnavailableError, TransportError, - after_response, - async_after_response, - async_before_request, - async_on_error, - before_request, ) from httpware._internal.status import STREAMING_BODY_MARKER @@ -151,6 +146,38 @@ def test_sync_invalid_url_on_request_and_with_response_maps_to_transport_error() client.get_with_response(_INVALID_URL, response_model=dict) +async def test_async_stream_on_closed_client_raises_transport_error() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok)) + await client.aclose() + with pytest.raises(TransportError, match="closed"): + async with client.stream("GET", "https://example.test/x"): + pass + + +def test_sync_stream_on_closed_client_raises_transport_error() -> None: + client = Client(transport=httpx2.MockTransport(_ok)) + client.close() + with pytest.raises(TransportError, match="closed"), client.stream("GET", "https://example.test/x"): + pass + + +async def test_async_stream_runtime_error_on_open_client_propagates() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok)) + msg = "user code failed" + with pytest.raises(RuntimeError, match=msg) as info: + async with client.stream("GET", "https://example.test/x"): + raise RuntimeError(msg) + assert not isinstance(info.value, TransportError) + + +def test_sync_stream_runtime_error_on_open_client_propagates() -> None: + client = Client(transport=httpx2.MockTransport(_ok)) + msg = "user code failed" + with pytest.raises(RuntimeError, match=msg) as info, client.stream("GET", "https://example.test/x"): + raise RuntimeError(msg) + assert not isinstance(info.value, TransportError) + + def _async_retry_client(handler: typing.Any) -> AsyncClient: # noqa: ANN401 return AsyncClient( transport=httpx2.MockTransport(handler), @@ -188,5 +215,3 @@ async def _async_swallow(request: httpx2.Request, exc: Exception) -> httpx2.Resp def _sync_function_middleware(request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 return next(request) - - From f83d1fa0c72c3fe58464801b3496a905b3b459d0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:40:17 +0300 Subject: [PATCH 5/8] fix: refuse retry for streaming bodies of requests passed to send() --- docs/resilience.md | 2 + src/httpware/_internal/status.py | 9 +++ src/httpware/client.py | 35 +++++++++-- tests/test_client_error_paths.py | 100 +++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 6 deletions(-) diff --git a/docs/resilience.md b/docs/resilience.md index 5adec2d..5b9b44e 100644 --- a/docs/resilience.md +++ b/docs/resilience.md @@ -50,6 +50,8 @@ If the request body was an async iterable, `AsyncRetry` does not retry, because httpware: not retrying because the request body is a stream that cannot replay across attempts ``` +The same applies to a request you build yourself and pass to `send()` or `send_with_response()`, whether it comes from `client.build_request()` or `httpx2.Request()`. `send()` marks it as streaming when its body is not held in memory and is not a multipart upload, so `content=` with a generator is marked, while `content=b"..."`, `json=`, `data=` and `files=` are not. A body built from a list of chunks also counts as streaming here, because `send()` cannot tell it from a generator; `client.post()` and the other verb methods see the original argument and don't mark it. + The note, and the `retry.streaming_refused` event on `httpware.retry`, appear only when the failure would otherwise have been retried. A POST with a streaming body, for example, is re-raised without the note because POST is not retried in the first place. See [Observability](observability.md). ### Exhaustion behavior diff --git a/src/httpware/_internal/status.py b/src/httpware/_internal/status.py index 0a5507f..94999d4 100644 --- a/src/httpware/_internal/status.py +++ b/src/httpware/_internal/status.py @@ -18,6 +18,15 @@ (the consumed iterator cannot replay across attempts).""" +def _mark_streaming_body(request: httpx2.Request) -> None: + """Set STREAMING_BODY_MARKER unless the body is in memory, multipart, or the marker is already set.""" + if isinstance(request.stream, httpx2.ByteStream) or STREAMING_BODY_MARKER in request.extensions: + return + if request.headers.get("content-type", "").startswith("multipart/form-data"): + return + request.extensions[STREAMING_BODY_MARKER] = True + + def _raise_on_status_error(response: httpx2.Response) -> None: """Raise the appropriate StatusError subclass for a 4xx/5xx response. No-op for 2xx/3xx.""" status = response.status_code diff --git a/src/httpware/client.py b/src/httpware/client.py index 393fa4f..5c9dfc0 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -20,6 +20,7 @@ STREAMING_BODY_MARKER, _is_streaming_body_async, _is_streaming_body_sync, + _mark_streaming_body, _raise_on_status_error, ) from httpware.decoders import ResponseDecoder @@ -427,7 +428,14 @@ async def send( *, response_model: type[T] | None = None, ) -> httpx2.Response | T: - """Send `request` through the middleware chain. Decode if `response_model` is set.""" + """Send `request` through the middleware chain. Decode if `response_model` is set. + + A request whose body is a one-shot stream gets STREAMING_BODY_MARKER, so retry middleware will not replay it. + """ + _mark_streaming_body(request) + return await self._send(request, response_model) + + async def _send(self, request: httpx2.Request, response_model: type[T] | None) -> httpx2.Response | T: if response_model is None: return await self._dispatch(request) @@ -450,6 +458,10 @@ async def send_with_response( Not for streaming responses — decodes ``response.content``, which requires the body to be fully read. Use ``stream()`` for streaming. """ + _mark_streaming_body(request) + return await self._send_with_response(request, response_model) + + async def _send_with_response(self, request: httpx2.Request, response_model: type[T]) -> tuple[httpx2.Response, T]: bound = self._decoder_resolver.resolve(response_model) response = await self._dispatch(request) return response, bound.decode(response) @@ -521,7 +533,7 @@ async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method sig data=data, files=files, ) - return await self.send(request, response_model=response_model) + return await self._send(request, response_model) async def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, @@ -552,7 +564,7 @@ async def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 data=data, files=files, ) - return await self.send_with_response(request, response_model=response_model) + return await self._send_with_response(request, response_model) @typing.overload async def get( @@ -1384,7 +1396,14 @@ def send( *, response_model: type[T] | None = None, ) -> httpx2.Response | T: - """Send `request` through the middleware chain. Decode if `response_model` is set.""" + """Send `request` through the middleware chain. Decode if `response_model` is set. + + A request whose body is a one-shot stream gets STREAMING_BODY_MARKER, so retry middleware will not replay it. + """ + _mark_streaming_body(request) + return self._send(request, response_model) + + def _send(self, request: httpx2.Request, response_model: type[T] | None) -> httpx2.Response | T: if response_model is None: return self._dispatch(request) @@ -1407,6 +1426,10 @@ def send_with_response( Not for streaming responses — decodes ``response.content``, which requires the body to be fully read. Use ``stream()`` for streaming. """ + _mark_streaming_body(request) + return self._send_with_response(request, response_model) + + 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) return response, bound.decode(response) @@ -1478,7 +1501,7 @@ def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signature data=data, files=files, ) - return self.send(request, response_model=response_model) + return self._send(request, response_model) def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-method signatures self, @@ -1509,7 +1532,7 @@ def _request_with_body_with_response( # noqa: PLR0913 — mirrors httpx2 per-me data=data, files=files, ) - return self.send_with_response(request, response_model=response_model) + return self._send_with_response(request, response_model) @typing.overload def get( diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py index 7f8dc62..cdc2dc5 100644 --- a/tests/test_client_error_paths.py +++ b/tests/test_client_error_paths.py @@ -192,6 +192,106 @@ def _sync_retry_client(handler: typing.Any) -> Client: # noqa: ANN401 ) +async def test_async_send_of_built_streaming_request_refuses_retry() -> None: + handler = _AsyncCounting503() + client = _async_retry_client(handler) + request = client.build_request("POST", "https://example.test/upload", content=_async_chunks()) + with pytest.raises(ServiceUnavailableError) as info: + await client.send(request) + assert handler.calls == 1 + assert _REFUSAL_NOTE in info.value.__notes__ + + +async def test_async_send_of_raw_streaming_request_refuses_retry() -> None: + handler = _AsyncCounting503() + client = _async_retry_client(handler) + request = httpx2.Request("POST", "https://example.test/upload", content=_async_chunks()) + with pytest.raises(ServiceUnavailableError) as info: + await client.send(request) + assert handler.calls == 1 + assert _REFUSAL_NOTE in info.value.__notes__ + + +async def test_async_send_with_response_of_raw_streaming_request_refuses_retry() -> None: + handler = _AsyncCounting503() + client = _async_retry_client(handler) + request = httpx2.Request("POST", "https://example.test/upload", content=_async_chunks()) + with pytest.raises(ServiceUnavailableError): + await client.send_with_response(request, response_model=dict) + assert handler.calls == 1 + + +def test_sync_send_of_built_streaming_request_refuses_retry() -> None: + handler = _Counting503() + client = _sync_retry_client(handler) + request = client.build_request("POST", "https://example.test/upload", content=_sync_chunks()) + with pytest.raises(ServiceUnavailableError) as info: + client.send(request) + assert handler.calls == 1 + assert _REFUSAL_NOTE in info.value.__notes__ + + +def test_sync_send_of_raw_streaming_request_refuses_retry() -> None: + handler = _Counting503() + client = _sync_retry_client(handler) + request = httpx2.Request("POST", "https://example.test/upload", content=_sync_chunks()) + with pytest.raises(ServiceUnavailableError) as info: + client.send(request) + assert handler.calls == 1 + assert _REFUSAL_NOTE in info.value.__notes__ + + +def test_sync_send_with_response_of_raw_streaming_request_refuses_retry() -> None: + handler = _Counting503() + client = _sync_retry_client(handler) + request = httpx2.Request("POST", "https://example.test/upload", content=_sync_chunks()) + with pytest.raises(ServiceUnavailableError): + client.send_with_response(request, response_model=dict) + assert handler.calls == 1 + + +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({"content": b"body"}, id="bytes"), + pytest.param({"json": {"a": 1}}, id="json"), + pytest.param({"data": {"a": "1"}}, id="form"), + pytest.param({"files": {"f": b"file"}}, id="multipart"), + ], +) +def test_sync_send_of_raw_replayable_request_is_not_marked(kwargs: dict[str, typing.Any]) -> None: + seen: list[dict[str, typing.Any]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(dict(request.extensions)) + return _ok(request) + + client = Client(transport=httpx2.MockTransport(handler)) + client.send(httpx2.Request("POST", "https://example.test/upload", **kwargs)) + assert STREAMING_BODY_MARKER not in seen[0] + + +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({"content": b"body"}, id="bytes"), + pytest.param({"json": {"a": 1}}, id="json"), + pytest.param({"data": {"a": "1"}}, id="form"), + pytest.param({"files": {"f": b"file"}}, id="multipart"), + ], +) +async def test_async_send_of_raw_replayable_request_is_not_marked(kwargs: dict[str, typing.Any]) -> None: + seen: list[dict[str, typing.Any]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(dict(request.extensions)) + return _ok(request) + + client = AsyncClient(transport=httpx2.MockTransport(handler)) + await client.send(httpx2.Request("POST", "https://example.test/upload", **kwargs)) + assert STREAMING_BODY_MARKER not in seen[0] + + class _AsyncPassThrough: async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002 return await next(request) From 210cc414e6adf984f2d4f32290ab2fe3119b6eff Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:41:22 +0300 Subject: [PATCH 6/8] fix: reject async middleware when building a sync Client --- docs/middleware.md | 2 +- src/httpware/client.py | 10 ++++++++++ tests/test_client_error_paths.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/middleware.md b/docs/middleware.md index bacbe8e..6a5f237 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -189,7 +189,7 @@ with Client(base_url="https://api.example.com", middleware=[add_request_id]) as client.get("/users/1") ``` -Sync and async middleware don't mix: pass `Middleware` to `Client` and `AsyncMiddleware` to `AsyncClient`. +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`. ## See also diff --git a/src/httpware/client.py b/src/httpware/client.py index 5c9dfc0..0920e88 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -1,6 +1,7 @@ """Client + AsyncClient — thin httpx2 wrappers with typed decoding and middleware.""" import contextlib +import inspect import ssl import typing from collections.abc import AsyncGenerator, Callable, Generator, Mapping, Sequence @@ -41,6 +42,7 @@ "event_hooks": "event_hooks=... is not supported; use middleware=... instead.", } _TOO_MANY_REDIRECTS_MESSAGE = "Exceeded maximum allowed redirects." +_ASYNC_MIDDLEWARE_MESSAGE = "Client() got async middleware {middleware!r}; pass AsyncMiddleware to AsyncClient instead." _NO_AUTH = httpx2.Auth() _BASE_URL_QUERY_MESSAGE = ( "base_url must not contain a query string: httpx2 appends request paths after it, " @@ -69,6 +71,13 @@ def _build_default_decoders() -> tuple[ResponseDecoder, ...]: return tuple(decoders) +def _reject_async_middleware(middleware: Sequence[Middleware]) -> None: + """Raise TypeError if any middleware, or its `__call__`, is a coroutine function.""" + for item in middleware: + if inspect.iscoroutinefunction(item) or inspect.iscoroutinefunction(type(item).__call__): + raise TypeError(_ASYNC_MIDDLEWARE_MESSAGE.format(middleware=item)) + + def _reject_base_url_query(base_url: httpx2.URL | str) -> None: """Raise ValueError if base_url carries a query string.""" if httpx2.URL(base_url).query: @@ -1328,6 +1337,7 @@ def __init__( **httpx2_options: typing.Unpack[_ClientOptions], ) -> None: _validate_max_response_body_bytes(max_response_body_bytes) + _reject_async_middleware(middleware) forwarded = _select_httpx2_options( type(self).__name__, httpx2_options, diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py index cdc2dc5..63facf1 100644 --- a/tests/test_client_error_paths.py +++ b/tests/test_client_error_paths.py @@ -17,6 +17,11 @@ Retry, ServiceUnavailableError, TransportError, + after_response, + async_after_response, + async_before_request, + async_on_error, + before_request, ) from httpware._internal.status import STREAMING_BODY_MARKER @@ -313,5 +318,32 @@ async def _async_swallow(request: httpx2.Request, exc: Exception) -> httpx2.Resp return None +@pytest.mark.parametrize( + "middleware", + [ + pytest.param(AsyncRetry(), id="AsyncRetry"), + pytest.param(_AsyncPassThrough(), id="class"), + pytest.param(_async_function_middleware, id="function"), + pytest.param(async_before_request(_async_identity), id="async_before_request"), + pytest.param(async_after_response(_async_response_identity), id="async_after_response"), + pytest.param(async_on_error(_async_swallow), id="async_on_error"), + ], +) +def test_sync_client_rejects_async_middleware(middleware: typing.Any) -> None: # noqa: ANN401 + with pytest.raises(TypeError, match="AsyncClient"): + Client(middleware=[middleware]) + + def _sync_function_middleware(request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 return next(request) + + +def test_sync_client_accepts_sync_middleware() -> None: + Client( + middleware=[ + Retry(), + _sync_function_middleware, + before_request(lambda request: request), + after_response(lambda _, response: response), + ], + ) From 9407276b8a4031617c93c6004c7f0f7a8656b091 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 11:47:11 +0300 Subject: [PATCH 7/8] fix: read an unread async response body before decoding it --- src/httpware/client.py | 4 ++++ tests/test_client_error_paths.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/httpware/client.py b/src/httpware/client.py index 0920e88..bedcde7 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -450,6 +450,8 @@ 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: + await response.aread() return bound.decode(response) async def send_with_response( @@ -473,6 +475,8 @@ async def send_with_response( async def _send_with_response(self, request: httpx2.Request, response_model: type[T]) -> tuple[httpx2.Response, T]: bound = self._decoder_resolver.resolve(response_model) response = await self._dispatch(request) + if not response.is_closed: + await response.aread() return response, bound.decode(response) def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py index 63facf1..5b7c87e 100644 --- a/tests/test_client_error_paths.py +++ b/tests/test_client_error_paths.py @@ -347,3 +347,35 @@ def test_sync_client_accepts_sync_middleware() -> None: after_response(lambda _, response: response), ], ) + + +async def _async_json_chunks() -> AsyncIterator[bytes]: + yield b'{"id": ' + yield b"1}" + + +class _UnreadAsyncStreamResponse: + async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002, ARG002 + return httpx2.Response(HTTPStatus.OK, content=_async_json_chunks(), request=request) + + +async def test_async_send_decodes_unread_async_stream_from_middleware() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok), middleware=[_UnreadAsyncStreamResponse()]) + decoded = await client.send(httpx2.Request("GET", "https://example.test/x"), response_model=dict) + assert decoded == {"id": 1} + + +async def test_async_send_with_response_decodes_unread_async_stream_from_middleware() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok), middleware=[_UnreadAsyncStreamResponse()]) + response, decoded = await client.send_with_response( + httpx2.Request("GET", "https://example.test/x"), response_model=dict + ) + assert decoded == {"id": 1} + assert response.content == b'{"id": 1}' + + +async def test_async_verb_decodes_unread_async_stream_from_middleware() -> None: + client = AsyncClient(transport=httpx2.MockTransport(_ok), middleware=[_UnreadAsyncStreamResponse()]) + 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} From 50ac14ef3f24b872cef34d12810ab886972c17b3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 12:10:41 +0300 Subject: [PATCH 8/8] test: run every line of the client error path tests --- tests/test_client_error_paths.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_client_error_paths.py b/tests/test_client_error_paths.py index 5b7c87e..5208fff 100644 --- a/tests/test_client_error_paths.py +++ b/tests/test_client_error_paths.py @@ -156,14 +156,14 @@ async def test_async_stream_on_closed_client_raises_transport_error() -> None: await client.aclose() with pytest.raises(TransportError, match="closed"): async with client.stream("GET", "https://example.test/x"): - pass + pass # pragma: no cover — never reached; opening the stream raises def test_sync_stream_on_closed_client_raises_transport_error() -> None: client = Client(transport=httpx2.MockTransport(_ok)) client.close() with pytest.raises(TransportError, match="closed"), client.stream("GET", "https://example.test/x"): - pass + pass # pragma: no cover — never reached; opening the stream raises async def test_async_stream_runtime_error_on_open_client_propagates() -> None: @@ -334,12 +334,34 @@ def test_sync_client_rejects_async_middleware(middleware: typing.Any) -> None: Client(middleware=[middleware]) +async def test_async_client_runs_the_middleware_sync_client_rejects() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + status = HTTPStatus.SERVICE_UNAVAILABLE if request.url.path == "/down" else HTTPStatus.OK + return httpx2.Response(status, request=request) + + client = AsyncClient( + transport=httpx2.MockTransport(handler), + middleware=[ + AsyncRetry(_sleep=_no_sleep), + _AsyncPassThrough(), + _async_function_middleware, + async_before_request(_async_identity), + async_on_error(_async_swallow), + async_after_response(_async_response_identity), + ], + ) + assert (await client.get("https://example.test/up")).status_code == HTTPStatus.OK + with pytest.raises(ServiceUnavailableError): + await client.get("https://example.test/down") + + def _sync_function_middleware(request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 return next(request) def test_sync_client_accepts_sync_middleware() -> None: - Client( + client = Client( + transport=httpx2.MockTransport(_ok), middleware=[ Retry(), _sync_function_middleware, @@ -347,6 +369,7 @@ def test_sync_client_accepts_sync_middleware() -> None: after_response(lambda _, response: response), ], ) + assert client.get("https://example.test/x").status_code == HTTPStatus.OK async def _async_json_chunks() -> AsyncIterator[bytes]: