Skip to content

Commit 660146e

Browse files
authored
fix: map InvalidURL in build_request; clear TypeError for sync-stream middleware responses (#189)
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
1 parent eb4b2d2 commit 660146e

3 files changed

Lines changed: 105 additions & 17 deletions

File tree

‎docs/middleware.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ with Client(base_url="https://api.example.com", middleware=[add_request_id]) as
191191

192192
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`.
193193

194+
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`.
195+
194196
## See also
195197

196198
- [Resilience](resilience.md): the built-in middleware and the order to compose them in.

‎src/httpware/client.py‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -400,18 +400,41 @@ def _request_kwargs(
400400
def _build_request(
401401
client: httpx2.Client | httpx2.AsyncClient, method: str, url: httpx2.URL | str, kwargs: dict[str, typing.Any]
402402
) -> httpx2.Request:
403-
"""Build via `client`, keeping the URL's own query bytes ahead of any params, which httpx2 would replace."""
404-
if (isinstance(url, str) and "?" not in url) or (kwargs.get("params") is None and not client.params):
405-
return client.build_request(method, url, **kwargs)
406-
parsed = httpx2.URL(url)
407-
if not parsed.query:
408-
return client.build_request(method, url, **kwargs)
409-
request = client.build_request(method, parsed.copy_with(query=None), **kwargs)
403+
"""Build via `client`, keeping the URL's own query bytes ahead of any params; map InvalidURL to TransportError."""
404+
try:
405+
if (isinstance(url, str) and "?" not in url) or (kwargs.get("params") is None and not client.params):
406+
return client.build_request(method, url, **kwargs)
407+
parsed = httpx2.URL(url)
408+
if not parsed.query:
409+
return client.build_request(method, url, **kwargs)
410+
request = client.build_request(method, parsed.copy_with(query=None), **kwargs)
411+
except httpx2.InvalidURL as exc:
412+
raise map_httpx2_exception(exc) from exc
410413
built_query = request.url.query
411414
request.url = request.url.copy_with(query=parsed.query + b"&" + built_query if built_query else parsed.query)
412415
return request
413416

414417

418+
def _reject_sync_stream(response: httpx2.Response) -> None:
419+
"""Raise TypeError unless an unread response from AsyncClient middleware has an async stream."""
420+
if not isinstance(response.stream, httpx2.AsyncByteStream):
421+
msg = (
422+
"AsyncClient middleware returned a response with a sync stream;"
423+
" AsyncClient middleware must return async-streamed responses"
424+
)
425+
raise TypeError(msg)
426+
427+
428+
def _reject_async_stream(response: httpx2.Response) -> None:
429+
"""Raise TypeError unless an unread response from Client middleware has a sync stream."""
430+
if not isinstance(response.stream, httpx2.SyncByteStream):
431+
msg = (
432+
"Client middleware returned a response with an async stream;"
433+
" Client middleware must return sync-streamed responses"
434+
)
435+
raise TypeError(msg)
436+
437+
415438
class AsyncClient:
416439
"""Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
417440

@@ -496,6 +519,7 @@ async def _send(self, request: httpx2.Request, response_model: type[T] | None) -
496519
bound = self._decoder_resolver.resolve(response_model)
497520
response = await self._dispatch(request)
498521
if not response.is_closed:
522+
_reject_sync_stream(response)
499523
await response.aread()
500524
return bound.decode(response)
501525

@@ -521,11 +545,15 @@ async def _send_with_response(self, request: httpx2.Request, response_model: typ
521545
bound = self._decoder_resolver.resolve(response_model)
522546
response = await self._dispatch(request)
523547
if not response.is_closed:
548+
_reject_sync_stream(response)
524549
await response.aread()
525550
return response, bound.decode(response)
526551

527552
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
528-
"""Delegate request construction to the wrapped httpx2.AsyncClient, keeping the URL's own query."""
553+
"""Delegate request construction to the wrapped httpx2.AsyncClient, keeping the URL's own query.
554+
555+
An invalid URL raises TransportError, with the httpx2.InvalidURL as `__cause__`.
556+
"""
529557
return _build_request(self._httpx2_client, method, url, kwargs)
530558

531559
def _prepare_request(
@@ -537,10 +565,7 @@ def _prepare_request(
537565
defaults: Mapping[str, object],
538566
) -> httpx2.Request:
539567
kwargs = _request_kwargs(self, caller, options, defaults)
540-
try:
541-
request = _build_request(self._httpx2_client, method, url, kwargs)
542-
except httpx2.InvalidURL as exc:
543-
raise map_httpx2_exception(exc) from exc
568+
request = _build_request(self._httpx2_client, method, url, kwargs)
544569
if ("content" in kwargs or "data" in kwargs or "files" in kwargs) and (
545570
_is_streaming_body_async(kwargs.get("content"))
546571
or _is_streaming_body_async(kwargs.get("data"))
@@ -1035,6 +1060,8 @@ def _send(self, request: httpx2.Request, response_model: type[T] | None) -> http
10351060

10361061
bound = self._decoder_resolver.resolve(response_model)
10371062
response = self._dispatch(request)
1063+
if not response.is_closed:
1064+
_reject_async_stream(response)
10381065
return bound.decode(response)
10391066

10401067
def send_with_response(
@@ -1058,10 +1085,15 @@ def send_with_response(
10581085
def _send_with_response(self, request: httpx2.Request, response_model: type[T]) -> tuple[httpx2.Response, T]:
10591086
bound = self._decoder_resolver.resolve(response_model)
10601087
response = self._dispatch(request)
1088+
if not response.is_closed:
1089+
_reject_async_stream(response)
10611090
return response, bound.decode(response)
10621091

10631092
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
1064-
"""Delegate request construction to the wrapped httpx2.Client, keeping the URL's own query."""
1093+
"""Delegate request construction to the wrapped httpx2.Client, keeping the URL's own query.
1094+
1095+
An invalid URL raises TransportError, with the httpx2.InvalidURL as `__cause__`.
1096+
"""
10651097
return _build_request(self._httpx2_client, method, url, kwargs)
10661098

10671099
def _prepare_request(
@@ -1073,10 +1105,7 @@ def _prepare_request(
10731105
defaults: Mapping[str, object],
10741106
) -> httpx2.Request:
10751107
kwargs = _request_kwargs(self, caller, options, defaults)
1076-
try:
1077-
request = _build_request(self._httpx2_client, method, url, kwargs)
1078-
except httpx2.InvalidURL as exc:
1079-
raise map_httpx2_exception(exc) from exc
1108+
request = _build_request(self._httpx2_client, method, url, kwargs)
10801109
if ("content" in kwargs or "data" in kwargs or "files" in kwargs) and (
10811110
_is_streaming_body_sync(kwargs.get("content"))
10821111
or _is_streaming_body_sync(kwargs.get("data"))

‎tests/test_client_error_paths.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,60 @@ async def test_async_verb_decodes_unread_async_stream_from_middleware() -> None:
402402
assert await client.get("https://example.test/x", response_model=dict) == {"id": 1}
403403
_, decoded = await client.get_with_response("https://example.test/x", response_model=dict)
404404
assert decoded == {"id": 1}
405+
406+
407+
async def test_async_build_request_invalid_url_maps_to_transport_error() -> None:
408+
client = AsyncClient(transport=httpx2.MockTransport(_ok))
409+
with pytest.raises(TransportError, match="non-printable") as info:
410+
client.build_request("GET", _INVALID_URL)
411+
assert not isinstance(info.value, NetworkError)
412+
assert isinstance(info.value.__cause__, httpx2.InvalidURL)
413+
414+
415+
def test_sync_build_request_invalid_url_maps_to_transport_error() -> None:
416+
client = Client(transport=httpx2.MockTransport(_ok))
417+
with pytest.raises(TransportError, match="non-printable") as info:
418+
client.build_request("GET", _INVALID_URL)
419+
assert not isinstance(info.value, NetworkError)
420+
assert isinstance(info.value.__cause__, httpx2.InvalidURL)
421+
422+
423+
def _sync_json_chunks() -> Iterator[bytes]:
424+
yield b'{"id": 1}'
425+
426+
427+
class _UnreadSyncStreamResponse:
428+
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002, ARG002
429+
return httpx2.Response(HTTPStatus.OK, content=_sync_json_chunks(), request=request)
430+
431+
432+
class _SyncMiddlewareAsyncStreamResponse:
433+
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, ARG002
434+
return httpx2.Response(HTTPStatus.OK, content=_async_json_chunks(), request=request)
435+
436+
437+
async def test_async_send_rejects_sync_stream_from_middleware() -> None:
438+
client = AsyncClient(transport=httpx2.MockTransport(_ok), middleware=[_UnreadSyncStreamResponse()])
439+
with pytest.raises(TypeError, match="sync stream"):
440+
await client.get("https://example.test/x", response_model=dict)
441+
with pytest.raises(TypeError, match="AsyncClient middleware must return"):
442+
await client.get_with_response("https://example.test/x", response_model=dict)
443+
444+
445+
def test_sync_send_rejects_async_stream_from_middleware() -> None:
446+
client = Client(transport=httpx2.MockTransport(_ok), middleware=[_SyncMiddlewareAsyncStreamResponse()])
447+
with pytest.raises(TypeError, match="async stream"):
448+
client.get("https://example.test/x", response_model=dict)
449+
with pytest.raises(TypeError, match="Client middleware must return"):
450+
client.get_with_response("https://example.test/x", response_model=dict)
451+
452+
453+
def test_sync_send_decodes_unread_sync_stream_from_middleware() -> None:
454+
class _UnreadSync:
455+
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, ARG002
456+
return httpx2.Response(HTTPStatus.OK, content=_sync_json_chunks(), request=request)
457+
458+
client = Client(transport=httpx2.MockTransport(_ok), middleware=[_UnreadSync()])
459+
assert client.get("https://example.test/x", response_model=dict) == {"id": 1}
460+
_, decoded = client.get_with_response("https://example.test/x", response_model=dict)
461+
assert decoded == {"id": 1}

0 commit comments

Comments
 (0)