Skip to content

Commit 46e6d7e

Browse files
committed
feat(client)!: AsyncClient takes decoders=[...] with type-dispatched routing
1 parent 2c82f7f commit 46e6d7e

5 files changed

Lines changed: 92 additions & 26 deletions

File tree

‎src/httpware/client.py‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_raise_on_status_error,
1717
)
1818
from httpware.decoders import ResponseDecoder
19-
from httpware.errors import DecodeError, TransportError
19+
from httpware.errors import DecodeError, MissingDecoderError, TransportError
2020
from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next
2121
from httpware.middleware.chain import compose, compose_async
2222

@@ -93,7 +93,7 @@ class AsyncClient:
9393

9494
_httpx2_client: httpx2.AsyncClient
9595
_owns_client: bool
96-
_decoder: ResponseDecoder
96+
_decoders: tuple[ResponseDecoder, ...]
9797
_user_middleware: tuple[AsyncMiddleware, ...]
9898
_dispatch: AsyncNext
9999

@@ -108,7 +108,7 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
108108
limits: httpx2.Limits | None = None,
109109
auth: httpx2.Auth | None = None,
110110
httpx2_client: httpx2.AsyncClient | None = None,
111-
decoder: ResponseDecoder | None = None,
111+
decoders: Sequence[ResponseDecoder] | None = None,
112112
middleware: Sequence[AsyncMiddleware] = (),
113113
) -> None:
114114
if httpx2_client is not None:
@@ -144,10 +144,17 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
144144
self._httpx2_client = httpx2.AsyncClient(**kwargs)
145145
self._owns_client = True
146146

147-
self._decoder = decoder if decoder is not None else _default_pydantic_decoder()
147+
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
148148
self._user_middleware = tuple(middleware)
149149
self._dispatch = compose_async(self._user_middleware, self._terminal)
150150

151+
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
152+
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
153+
for decoder in self._decoders:
154+
if decoder.can_decode(model):
155+
return decoder
156+
return None
157+
151158
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
152159
try:
153160
async with _httpx2_exception_mapper():
@@ -172,11 +179,19 @@ async def send(
172179
response_model: type[T] | None = None,
173180
) -> httpx2.Response | T:
174181
"""Send `request` through the middleware chain. Decode if `response_model` is set."""
175-
response = await self._dispatch(request)
176182
if response_model is None:
177-
return response
183+
return await self._dispatch(request)
184+
185+
decoder = self._dispatch_decoder(response_model)
186+
if decoder is None:
187+
raise MissingDecoderError(
188+
model=response_model,
189+
registered_names=tuple(type(d).__name__ for d in self._decoders),
190+
)
191+
192+
response = await self._dispatch(request)
178193
try:
179-
return self._decoder.decode(response.content, response_model)
194+
return decoder.decode(response.content, response_model)
180195
except Exception as exc:
181196
raise DecodeError(response=response, model=response_model, original=exc) from exc
182197

@@ -195,9 +210,16 @@ async def send_with_response(
195210
Not for streaming responses — decodes ``response.content``, which
196211
requires the body to be fully read. Use ``stream()`` for streaming.
197212
"""
213+
decoder = self._dispatch_decoder(response_model)
214+
if decoder is None:
215+
raise MissingDecoderError(
216+
model=response_model,
217+
registered_names=tuple(type(d).__name__ for d in self._decoders),
218+
)
219+
198220
response = await self._dispatch(request)
199221
try:
200-
decoded = self._decoder.decode(response.content, response_model)
222+
decoded = decoder.decode(response.content, response_model)
201223
except Exception as exc:
202224
raise DecodeError(response=response, model=response_model, original=exc) from exc
203225
return response, decoded

‎tests/test_client_construction.py‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import httpx2
66
import pytest
77

8-
from httpware import AsyncClient
8+
from httpware import AsyncClient, MissingDecoderError
99
from httpware.client import _build_default_decoders
1010
from httpware.decoders.msgspec import MsgspecDecoder
1111
from httpware.decoders.pydantic import PydanticDecoder
@@ -52,21 +52,48 @@ def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) ->
5252
AsyncClient(httpx2_client=caller, **kwargs)
5353

5454

55-
def test_default_decoder_is_pydantic_decoder() -> None:
55+
def test_default_decoders_includes_pydantic_when_installed() -> None:
5656
client = AsyncClient()
57-
assert isinstance(client._decoder, PydanticDecoder) # noqa: SLF001
57+
assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001
5858

5959

60-
def test_explicit_decoder_is_honored() -> None:
60+
def test_explicit_decoders_is_honored() -> None:
6161
class _Stub:
6262
def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover
6363
return True
6464

6565
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover
6666
return None
6767

68-
client = AsyncClient(decoder=_Stub())
69-
assert isinstance(client._decoder, _Stub) # noqa: SLF001
68+
stub = _Stub()
69+
client = AsyncClient(decoders=[stub])
70+
assert client._decoders == (stub,) # noqa: SLF001
71+
72+
73+
def test_empty_decoders_is_honored() -> None:
74+
client = AsyncClient(decoders=[])
75+
assert client._decoders == () # noqa: SLF001
76+
77+
78+
async def test_missing_decoder_raised_before_http_call() -> None:
79+
"""response_model with no claiming decoder raises before the transport is invoked."""
80+
81+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
82+
pytest.fail("transport should not be invoked when MissingDecoderError fires")
83+
84+
transport = httpx2.MockTransport(handler)
85+
client = AsyncClient(
86+
httpx2_client=httpx2.AsyncClient(transport=transport),
87+
decoders=[],
88+
)
89+
90+
class _Foo:
91+
pass
92+
93+
with pytest.raises(MissingDecoderError) as exc_info:
94+
await client.get("https://example.test/x", response_model=_Foo)
95+
assert exc_info.value.model is _Foo
96+
assert exc_info.value.registered_names == ()
7097

7198

7299
@pytest.mark.parametrize(

‎tests/test_client_send_with_response.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pydantic
77
import pytest
88

9-
from httpware import AsyncClient, ClientError, DecodeError, NotFoundError
9+
from httpware import AsyncClient, ClientError, DecodeError, MissingDecoderError, NotFoundError
1010
from httpware.middleware import async_before_request
1111

1212

@@ -128,3 +128,21 @@ def handler(request: httpx2.Request) -> httpx2.Response:
128128
response, _ = await client.send_with_response(request, response_model=_User)
129129
assert recorded[0].headers.get("x-test") == "ok"
130130
assert response.request.headers.get("x-test") == "ok"
131+
132+
133+
async def test_send_with_response_raises_missing_decoder_before_http_call() -> None:
134+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
135+
pytest.fail("transport should not be invoked when MissingDecoderError fires")
136+
137+
transport = httpx2.MockTransport(handler)
138+
client = AsyncClient(
139+
httpx2_client=httpx2.AsyncClient(transport=transport),
140+
decoders=[],
141+
)
142+
143+
class _Foo:
144+
pass
145+
146+
request = client.build_request("GET", "https://example.test/x")
147+
with pytest.raises(MissingDecoderError):
148+
await client.send_with_response(request, response_model=_Foo)

‎tests/test_decoders_msgspec.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def handler(request: httpx2.Request) -> httpx2.Response:
6666
transport = httpx2.MockTransport(handler)
6767
client = AsyncClient(
6868
httpx2_client=httpx2.AsyncClient(transport=transport),
69-
decoder=MsgspecDecoder(),
69+
decoders=[MsgspecDecoder()],
7070
)
7171
with pytest.raises(DecodeError) as exc_info:
7272
await client.get("https://example.test/x", response_model=_Item)

‎tests/test_optional_extras_pydantic_missing.py‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,11 @@ def test_pydantic_decoder_init_raises_when_pydantic_missing() -> None:
3232
PydanticDecoder()
3333

3434

35-
def test_async_client_default_decoder_raises_when_pydantic_missing() -> None:
36-
with (
37-
patch("httpware._internal.import_checker.is_pydantic_installed", False),
38-
pytest.raises(ImportError, match=r"httpware\[pydantic\]"),
39-
):
40-
AsyncClient()
35+
def test_async_client_no_pydantic_constructs_without_raising() -> None:
36+
"""AsyncClient() with pydantic missing must not raise — lazy default policy."""
37+
with patch("httpware._internal.import_checker.is_pydantic_installed", False):
38+
client = AsyncClient()
39+
assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001
4140

4241

4342
def test_sync_client_default_decoder_raises_when_pydantic_missing() -> None:
@@ -48,12 +47,12 @@ def test_sync_client_default_decoder_raises_when_pydantic_missing() -> None:
4847
Client()
4948

5049

51-
def test_async_client_accepts_explicit_decoder_without_pydantic() -> None:
52-
"""An explicit decoder= escapes the fail-fast AND is actually wired to the client."""
50+
def test_async_client_accepts_explicit_decoders_without_pydantic() -> None:
51+
"""An explicit decoders= list is honored regardless of pydantic install state."""
5352
fake = _FakeDecoder()
5453
with patch("httpware._internal.import_checker.is_pydantic_installed", False):
55-
client = AsyncClient(decoder=fake)
56-
assert client._decoder is fake # noqa: SLF001 — wired the explicit decoder, not a default
54+
client = AsyncClient(decoders=[fake])
55+
assert client._decoders == (fake,) # noqa: SLF001
5756

5857

5958
def test_sync_client_accepts_explicit_decoder_without_pydantic() -> None:

0 commit comments

Comments
 (0)