Skip to content

Commit 75f8544

Browse files
authored
fix: decoder cache keys, msgspec field claims, unread middleware responses (#168)
* fix: order-aware decoder cache, msgspec field claims, unread responses - Key non-class models by themselves plus their __args__ in order, so `A | B` and `B | A` (and `list[A | B]` vs `list[B | A]`) no longer share a TypeAdapter; plain classes keep the identity key. - Replace the verdict cache and adapter cache with one dict per decoder (key -> adapter or None) read by both can_decode and decode. - Key unhashable models by repr with an equality check, so their adapter is built once instead of on every request. - MsgspecDecoder.can_decode walks field types of Structs, dataclasses, TypedDicts and NamedTuples (cycle-safe) and rejects models that would need a dec_hook, so MissingDecoderError fires before the request. - _BoundDecoder reads an unread response body before decoding, inside the DecodeError wrap. * fix: type the TypeAdapter builder argument as Any for pydantic 2.14 stubs * test: build repr-collision models in a form pydantic 2.0 accepts
1 parent b00487c commit 75f8544

8 files changed

Lines changed: 348 additions & 144 deletions

File tree

‎docs/decoders.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ Both clients take `decoders: Sequence[ResponseDecoder] | None = None`. The list
3333
- Order is preference. With `decoders=[CsvDecoder(), PydanticDecoder()]`, pydantic only sees the types the CSV decoder declined. When two decoders could both handle a type, the earlier one wins.
3434
- `decoders=None` uses the installed extras: pydantic then msgspec when both are installed, whichever one is installed, or no decoders at all. Passing a list replaces the defaults, so include the built-ins you still want: `decoders=[CsvDecoder(), PydanticDecoder()]`.
3535
- If `response_model=` is set and no decoder accepts it, the client raises `MissingDecoderError` before sending the request. That means nothing handles the type, and the fix is to install an extra or pass `decoders=[...]`. A `DecodeError` instead means a decoder ran and the body didn't fit the type, which points at the server or the model. See [Errors](errors.md).
36+
- `MsgspecDecoder` accepts a `Struct`, dataclass, `TypedDict` or `NamedTuple` only if msgspec can decode every field type itself, nested fields included. A field typed as a pydantic model or some other class would need a `dec_hook`, which `MsgspecDecoder` doesn't take, so it declines the model and you get `MissingDecoderError` before the request goes out.
3637

3738
## One sync protocol for both clients
3839

39-
Middleware comes in sync and async versions, but there is only one `ResponseDecoder` protocol, used by both clients. `decode` is synchronous because the body has already been read when it runs, so there is nothing to await. The same decoder works with either client.
40+
Middleware comes in sync and async versions, but there is only one `ResponseDecoder` protocol, used by both clients. `decode` is synchronous because the client has the whole body by the time it runs, so there is nothing to await. If a middleware returns a streamed response it never read, the client reads it before decoding. In `AsyncClient` that stream also has to support sync iteration, as `httpx2.ByteStream` does; otherwise the middleware should `await response.aread()` before returning it. The same decoder works with either client.
4041

4142
## Writing your own
4243

@@ -131,5 +132,5 @@ This decoder makes two passes: `json.loads`, then `structure`. The built-ins dec
131132

132133
## See also
133134

134-
- [`src/httpware/decoders/`](https://github.com/modern-python/httpware/tree/main/src/httpware/decoders): the built-in decoders, including how they cache `can_decode` results and parsers per type.
135+
- [`src/httpware/decoders/`](https://github.com/modern-python/httpware/tree/main/src/httpware/decoders): the built-in decoders, including how each caches one parser, or one rejection, per type.
135136
- [Quickstart: typed responses](index.md#typed-responses): `response_model=` with the default decoders.

‎src/httpware/decoders/_caching.py‎

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,52 @@
1-
"""Shared memoizing get-or-build cache for the decoder adapters (Seam C).
1+
"""One cache per decoder, keyed so that union member order counts (Seam C).
22
3-
Imports neither pydantic nor msgspec — safe for both decoder modules to
4-
import without crossing Seam C's per-extra import isolation.
3+
Imports neither pydantic nor msgspec, so both decoder modules can use it
4+
without crossing Seam C's per-extra import isolation.
55
"""
66

77
import typing
8+
from collections.abc import Callable, Hashable
89

910

1011
V = typing.TypeVar("V")
1112

13+
_Cache: typing.TypeAlias = dict[Hashable, tuple[object, V | None]]
1214

13-
def _get_or_build(cache: dict[type, V], model: type, build: typing.Callable[[], V]) -> V:
14-
"""Return cache[model], building and memoizing it via build() on miss.
1515

16-
Unhashable models bypass the cache entirely: dict.get(model) raises
17-
TypeError, in which case this returns a fresh, uncached build() every
18-
call rather than raising. A real cached value (bool verdict or a
19-
built adapter/decoder) is never None, so a None result unambiguously
20-
means "not cached yet" — callers must not store None as a value.
16+
def _key(model: object) -> Hashable:
17+
"""Return a key that tells `int | str` from `str | int`, which compare equal.
18+
19+
A class keys by identity; anything else by itself plus the keys of its
20+
`__args__`, in order.
21+
"""
22+
if isinstance(model, type):
23+
return model
24+
return (model, *map(_key, getattr(model, "__args__", ())))
25+
26+
27+
def _lookup(cache: "_Cache[V]", model: object, build: Callable[[typing.Any], V | None]) -> V | None:
28+
"""Return the cached `build(model)`, building it on a miss; None means `model` cannot be decoded.
29+
30+
`build` must not raise. Concurrent first calls may build twice; the last write wins.
2131
"""
2232
try:
23-
cached = cache.get(model)
24-
except TypeError:
25-
return build()
26-
if cached is not None:
27-
return cached
28-
result = build()
29-
cache[model] = result
30-
return result
33+
key = _key(model)
34+
entry = cache.get(key)
35+
except Exception: # noqa: BLE001 — unhashable models fall back to a repr key
36+
return _lookup_by_repr(cache, model, build)
37+
if entry is None:
38+
entry = cache[key] = (model, build(model))
39+
return entry[1]
40+
41+
42+
def _lookup_by_repr(cache: "_Cache[V]", model: object, build: Callable[[typing.Any], V | None]) -> V | None:
43+
"""Key an unhashable `model` by its repr; the equality check keeps two models with one repr apart."""
44+
try:
45+
key = repr(model)
46+
entry = cache.get(key)
47+
if entry is not None and entry[0] == model:
48+
return entry[1]
49+
except Exception: # noqa: BLE001 — a model that cannot be keyed is built uncached
50+
return build(model)
51+
entry = cache[key] = (model, build(model))
52+
return entry[1]

‎src/httpware/decoders/_resolver.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None:
3636
self._model = model
3737

3838
def decode(self, response: httpx2.Response) -> T:
39-
"""Decode `response.content` into `model`, wrapping any failure as `DecodeError`."""
39+
"""Read the body if needed and decode it into `model`, wrapping any failure as `DecodeError`."""
4040
try:
41-
return self._decoder.decode(response.content, self._model)
41+
return self._decoder.decode(response.read(), self._model)
4242
except Exception as exc:
4343
raise DecodeError(response=response, model=self._model, original=exc) from exc
4444

‎src/httpware/decoders/msgspec.py‎

Lines changed: 53 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""MsgspecDecoder — opt-in ResponseDecoder backed by a per-instance msgspec.json.Decoder cache."""
22

33
import typing
4+
from collections.abc import Iterator
45
from typing import TypeVar
56

67
from httpware._internal import import_checker
7-
from httpware.decoders._caching import _get_or_build
8+
from httpware.decoders._caching import _Cache, _lookup
89

910

1011
if import_checker.is_msgspec_installed:
@@ -16,36 +17,50 @@
1617
T = TypeVar("T")
1718

1819

19-
def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
20-
"""Return True if `info` is a CustomType or nests one in its parameters.
20+
def _child_types(info: "msgspec.inspect.Type") -> Iterator["msgspec.inspect.Type"]:
21+
"""Yield the types nested in `info`: container parameters and the field types of a Struct-like model."""
22+
for name in info.__struct_fields__:
23+
value = getattr(info, name)
24+
if isinstance(value, msgspec.inspect.Type):
25+
yield value
26+
elif isinstance(value, tuple):
27+
for item in value:
28+
if isinstance(item, msgspec.inspect.Type):
29+
yield item
30+
elif isinstance(item, msgspec.inspect.Field):
31+
yield item.type
32+
2133

22-
Walks generic-container parameterization (list/dict/set/tuple/union element
23-
types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a
24-
tuple of them. It deliberately does NOT descend into `StructType`/dataclass
25-
fields: those expose `fields` as `Field` objects (not `Type`), so the walk
26-
stops at the boundary of a type msgspec natively owns. That boundary is what
27-
makes the walk both correct (a Struct is a valid target) and safe against
28-
infinite recursion on self-referential struct definitions.
34+
def _is_native(info: "msgspec.inspect.Type") -> bool:
35+
"""Return True iff no `CustomType` appears anywhere in the type graph rooted at `info`.
36+
37+
The graph is cyclic for self-referential models, so each node is visited once.
2938
"""
30-
if not import_checker.is_msgspec_installed:
31-
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
32-
if isinstance(info, msgspec.inspect.CustomType):
33-
return True
34-
for name in dir(info):
35-
if name.startswith("_"):
39+
seen: set[int] = set()
40+
stack = [info]
41+
while stack:
42+
node = stack.pop()
43+
if isinstance(node, msgspec.inspect.CustomType):
44+
return False
45+
if id(node) in seen:
3646
continue
37-
value = getattr(info, name, None)
38-
if isinstance(value, msgspec.inspect.Type):
39-
if _contains_custom_type(value):
40-
return True
41-
elif (
42-
isinstance(value, tuple)
43-
and value
44-
and all(isinstance(item, msgspec.inspect.Type) for item in value)
45-
and any(_contains_custom_type(item) for item in value)
46-
):
47-
return True
48-
return False
47+
seen.add(id(node))
48+
stack.extend(_child_types(node))
49+
return True
50+
51+
52+
def _build_adapter(model: object) -> "msgspec.json.Decoder[typing.Any] | None":
53+
"""Return a `msgspec.json.Decoder` for `model`, or None when msgspec cannot decode it natively.
54+
55+
msgspec builds a Decoder for almost any class through its `CustomType`
56+
fallback, so the type graph is checked for one first, field types included.
57+
"""
58+
try:
59+
if not _is_native(msgspec.inspect.type_info(model)):
60+
return None
61+
return msgspec.json.Decoder(model)
62+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
63+
return None
4964

5065

5166
class MsgspecDecoder:
@@ -56,49 +71,25 @@ class MsgspecDecoder:
5671
`find_spec` check), but instantiating the decoder raises `ImportError`.
5772
"""
5873

59-
_msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
60-
_can_decode_results: dict[type, bool]
74+
_adapters: "_Cache[msgspec.json.Decoder[typing.Any]]"
6175

6276
def __init__(self) -> None:
6377
if not import_checker.is_msgspec_installed:
6478
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
65-
self._msgspec_decoders = {}
66-
self._can_decode_results = {}
67-
68-
def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
69-
return _get_or_build(self._msgspec_decoders, model, lambda: msgspec.json.Decoder(model))
79+
self._adapters = {}
7080

7181
def can_decode(self, model: type) -> bool:
7282
"""Return True iff msgspec natively understands `model` end-to-end.
7383
74-
The verdict is memoized per `model`: the probe below (an uncached
75-
`type_info` call plus a recursive tree walk) runs once per type, not on
76-
every dispatch. Unhashable models skip the cache and probe fresh.
77-
"""
78-
return _get_or_build(self._can_decode_results, model, lambda: self._probe_can_decode(model))
79-
80-
def _probe_can_decode(self, model: type) -> bool:
81-
"""Decide whether msgspec natively decodes `model` (the uncached path).
82-
83-
msgspec builds a Decoder for almost any class via a generic CustomType
84-
fallback; the Decoder constructor does NOT raise on unsupported types
85-
(e.g. pydantic.BaseModel, or a container parameterized by one). We walk
86-
msgspec.inspect.type_info and reject if a CustomType appears anywhere in
87-
the type tree, so MissingDecoderError fires before a request is sent.
84+
Rejects a model if any type in it, including the field types of a
85+
Struct, dataclass, TypedDict or NamedTuple, would need a `dec_hook`.
86+
The adapter, or the rejection, is cached per `model`.
8887
"""
89-
try:
90-
info = msgspec.inspect.type_info(model)
91-
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
92-
return False
93-
if _contains_custom_type(info):
94-
return False
95-
try:
96-
self._get_msgspec_decoder(model)
97-
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
98-
return False
99-
return True
88+
return _lookup(self._adapters, model, _build_adapter) is not None
10089

10190
def decode(self, content: bytes, model: type[T]) -> T:
10291
"""Validate `content` as JSON against `model` in a single parse pass."""
103-
decoder = self._get_msgspec_decoder(model)
104-
return decoder.decode(content)
92+
adapter = _lookup(self._adapters, model, _build_adapter)
93+
if adapter is None:
94+
adapter = msgspec.json.Decoder(model) # decoding then raises msgspec's own error
95+
return adapter.decode(content)

‎src/httpware/decoders/pydantic.py‎

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does
1111
from typing import TypeVar
1212

1313
from httpware._internal import import_checker
14-
from httpware.decoders._caching import _get_or_build
14+
from httpware.decoders._caching import _Cache, _lookup
1515

1616

1717
if import_checker.is_pydantic_installed:
@@ -25,6 +25,14 @@ class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does
2525
T = TypeVar("T")
2626

2727

28+
def _build_adapter(model: typing.Any) -> "TypeAdapter[typing.Any] | None": # noqa: ANN401
29+
"""Return a `TypeAdapter` for `model`, or None when pydantic cannot build a schema for it."""
30+
try:
31+
return TypeAdapter(model)
32+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
33+
return None
34+
35+
2836
class PydanticDecoder:
2937
"""Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`.
3038
@@ -34,42 +42,25 @@ class PydanticDecoder:
3442
`ImportError`.
3543
"""
3644

37-
_adapters: dict[type, "TypeAdapter[typing.Any]"]
38-
_can_decode_results: dict[type, bool]
45+
_adapters: "_Cache[TypeAdapter[typing.Any]]"
3946

4047
def __init__(self) -> None:
4148
if not import_checker.is_pydantic_installed:
4249
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
4350
self._adapters = {}
44-
self._can_decode_results = {}
45-
46-
def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]":
47-
return _get_or_build(self._adapters, model, lambda: TypeAdapter(model))
4851

4952
def can_decode(self, model: type) -> bool:
5053
"""Return True iff pydantic can build a schema for `model`.
5154
52-
The verdict is memoized per `model` so a rejection (which costs a
53-
`PydanticSchemaGenerationError` round-trip) is not re-probed on every
54-
dispatch. Unhashable models skip the cache and probe fresh.
55-
"""
56-
return _get_or_build(self._can_decode_results, model, lambda: self._probe_can_decode(model))
57-
58-
def _probe_can_decode(self, model: type) -> bool:
59-
"""Decide whether pydantic can build a schema for `model` (uncached).
60-
61-
Probes via `_get_adapter`; subsequent calls (including `decode`) reuse
62-
the cached `TypeAdapter`. Rejects `msgspec.Struct` subclasses —
63-
pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when
64-
building a schema for them.
55+
The adapter, or the rejection, is cached per `model`, so `decode` reuses
56+
what this built. `msgspec.Struct` subclasses are rejected: pydantic
57+
cannot build a schema for them.
6558
"""
66-
try:
67-
self._get_adapter(model)
68-
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
69-
return False
70-
return True
59+
return _lookup(self._adapters, model, _build_adapter) is not None
7160

7261
def decode(self, content: bytes, model: type[T]) -> T:
7362
"""Validate `content` as JSON against `model` in a single parse pass."""
74-
adapter = self._get_adapter(model)
63+
adapter = _lookup(self._adapters, model, _build_adapter)
64+
if adapter is None:
65+
adapter = TypeAdapter(model) # raises pydantic's own schema error
7566
return adapter.validate_json(content)

‎tests/test_decoder_resolver.py‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Seam-level tests for _DecoderResolver / _BoundDecoder.
22
3-
Drives resolve() and _BoundDecoder.decode() directly with a fake decoder list —
4-
no client, no MockTransport. Covers resolution, the pre-flight MissingDecoderError
5-
(with registered_names + ordering), first-match-wins, and DecodeError wrapping.
3+
Drives resolve() and _BoundDecoder.decode() directly with a fake decoder list.
4+
Covers resolution, the pre-flight MissingDecoderError (with registered_names +
5+
ordering), first-match-wins, and DecodeError wrapping. The last tests run both
6+
clients over MockTransport to decode a response a middleware built but never read.
67
"""
78

89
import httpx2
910
import pytest
1011

12+
from httpware import AsyncClient, Client
1113
from httpware.decoders._resolver import _DecoderResolver
1214
from httpware.errors import DecodeError, MissingDecoderError
1315

@@ -83,3 +85,48 @@ def test_decode_failure_wraps_as_decode_error() -> None:
8385
assert ei.value.model is _Wanted
8486
assert ei.value.original is boom
8587
assert ei.value.__cause__ is boom
88+
89+
90+
class _EchoDecoder:
91+
def can_decode(self, model: type) -> bool:
92+
return model is bytes
93+
94+
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — echoes the body
95+
return content
96+
97+
98+
def _unread_response() -> httpx2.Response:
99+
return httpx2.Response(200, stream=httpx2.ByteStream(b"streamed"))
100+
101+
102+
def test_bound_decoder_reads_an_unread_response() -> None:
103+
bound = _DecoderResolver((_EchoDecoder(),)).resolve(bytes)
104+
assert bound.decode(_unread_response()) == b"streamed"
105+
106+
107+
def _never_called(request: httpx2.Request) -> httpx2.Response: # pragma: no cover — the request must not go out
108+
raise AssertionError(request.url)
109+
110+
111+
def test_client_decodes_unread_response_from_middleware() -> None:
112+
def synthesize(request: httpx2.Request, next: object) -> httpx2.Response: # noqa: A002, ARG001
113+
return _unread_response()
114+
115+
client = Client(
116+
httpx2_client=httpx2.Client(transport=httpx2.MockTransport(_never_called)),
117+
decoders=[_EchoDecoder()],
118+
middleware=[synthesize],
119+
)
120+
assert client.get("https://example.test/x", response_model=bytes) == b"streamed"
121+
122+
123+
async def test_async_client_decodes_unread_response_from_middleware() -> None:
124+
async def synthesize(request: httpx2.Request, next: object) -> httpx2.Response: # noqa: A002, ARG001
125+
return _unread_response()
126+
127+
client = AsyncClient(
128+
httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(_never_called)),
129+
decoders=[_EchoDecoder()],
130+
middleware=[synthesize],
131+
)
132+
assert await client.get("https://example.test/x", response_model=bytes) == b"streamed"

0 commit comments

Comments
 (0)