Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 46 additions & 17 deletions src/httpware/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)

Expand All @@ -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(
Expand All @@ -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"))
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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"))
Expand Down
57 changes: 57 additions & 0 deletions tests/test_client_error_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Loading