Skip to content

fix: decoder cache keys, msgspec field claims, unread middleware responses - #168

Merged
lesnik512 merged 3 commits into
mainfrom
fix/decoder-fixes
Oct 17, 2026
Merged

lesnik512 merged 3 commits into
mainfrom
fix/decoder-fixes

Conversation

@lesnik512

Copy link
Copy Markdown
Member

What changed

Union order in the cache key

PydanticDecoder and MsgspecDecoder cached per model with plain dict lookup, and A | B == B | A hashes and compares equal. Once A | B was decoded, a later B | A got the same TypeAdapter. Pydantic's smart union breaks ties by member order, so the second call returned an A where it should have returned a B. The same applied inside generics (list[A | B] vs list[B | A]). A plain class still keys by identity. Anything else keys by itself plus the keys of its __args__ in order, so the two unions get different entries.

I used this structural key instead of repr(model) because repr cost about 0.9 to 1.6 µs per lookup on generics, against about 0.35 µs for the structural key (see the numbers below).

One cache per decoder

Each decoder had a verdict cache and an adapter cache, filled through _caching._get_or_build, which allocated a lambda on every call. Each decoder now has one dict mapping key to (model, adapter or None), and None means "cannot decode". can_decode and decode read the same entry. _caching.py now holds the key function and the lookup. Thread safety is the same as before: a single dict.get and a single store, so concurrent first calls can build twice and the last write wins, but the dict is never corrupted.

Unhashable models

A model such as Annotated[list[int], {"a": 1}] used to skip the cache, so every request built a new TypeAdapter or msgspec.json.Decoder (twice: once in can_decode, once in decode). It now falls back to a repr key. The entry keeps the model and checks equality on a hit, so two unhashable models that happen to share a repr (two classes with the same qualified name) don't share an adapter. A model whose repr raises is built uncached, as before, so can_decode still never raises.

msgspec over-claiming

MsgspecDecoder.can_decode looked for msgspec's CustomType fallback in container parameters but stopped at a Struct, dataclass, TypedDict or NamedTuple. A Struct with a pydantic-model field was therefore claimed, the request went out, and decoding failed afterwards. The walk now descends into field types as well, follows unions, containers and Annotated, and tracks visited nodes so self-referential models terminate (msgspec's type graph is cyclic for them). Such models are now rejected, and the resolver raises MissingDecoderError before the request, as ADR-0005 intends. The unreachable is_msgspec_installed check inside the walk is gone.

Unread middleware responses

_BoundDecoder.decode passed response.content to the decoder. A middleware that returns a streamed response it never read made that raise ResponseNotRead, which surfaced as DecodeError(original=ResponseNotRead). It now calls response.read(), which returns the cached body when it is already read. I put the read inside the DecodeError wrap. can_decode still runs in resolve(), outside the wrap. If the read sat outside the wrap, a stream failure would escape as a raw httpx2/RuntimeError instead of a ClientError.

Behaviour changes for 0.22.0

  • MsgspecDecoder no longer claims a Struct, dataclass, TypedDict or NamedTuple with a field msgspec cannot decode without a dec_hook (a pydantic model, an arbitrary class). These requests now fail with MissingDecoderError before sending. They used to send and then fail with DecodeError. With the default list (pydantic first), a dataclass or TypedDict like that still goes to PydanticDecoder as before.
  • B | A decodes with its own adapter after A | B has been seen, so pydantic's member-order tie-break now applies per call site.
  • A middleware-synthesized streamed response that was never read now decodes. Known gap: in AsyncClient the read is synchronous, so a stream that only supports async iteration (an async generator passed as content=) still ends in DecodeError(original=RuntimeError). Fixing that needs AsyncClient.send/send_with_response in client.py to await response.aread() before bound.decode(response), and client.py is outside this PR. httpx2.ByteStream, which supports both, works in either client. docs/decoders.md describes this.
  • Private API only: PydanticDecoder._can_decode_results, _get_adapter, MsgspecDecoder._msgspec_decoders, _can_decode_results, _get_msgspec_decoder, msgspec._contains_custom_type and _caching._get_or_build are gone. Both decoders now have _adapters.

Performance

Measured in one process, old modules (copied from main) against new, with the model built per call the way a call site does. Best of 9, 100k iterations, CPython 3.14, pydantic 2.13.5, msgspec 0.21.1:

model old can_decode new can_decode old can_decode + decode new can_decode + decode
P (pydantic class) 162 ns 116 ns 904 ns 824 ns
S (msgspec Struct) 161 ns 109 ns 440 ns 337 ns
list[P] 259 ns 407 ns 1236 ns 1544 ns
P | None 355 ns 543 ns 1305 ns 1706 ns
list[S] 255 ns 405 ns 670 ns 977 ns

Plain classes are about 30% cheaper per lookup. The order-aware key adds about 150 to 190 ns per lookup on generics and unions, or 300 to 400 ns per request because send looks up twice (can_decode in resolve, then decode). Resolver memoization would remove the second lookup and is left for the perf PR.

Tests

Each fix started with a failing test:

  • test_union_member_order_is_part_of_the_cache_key and test_nested_union_member_order_is_part_of_the_cache_key failed with assert <class '_Left'> is _Right.
  • test_unhashable_model_builds_adapter_once (pydantic) and test_unhashable_model_builds_decoder_once (msgspec) failed with assert 6 == 1.
  • test_msgspec_rejects_models_with_fields_it_cannot_decode (9 cases: Struct, optional list, nested Struct, list[...], dataclass, TypedDict, NamedTuple, self-referential, Annotated) failed with assert True is False. The sync and async client tests failed because the transport was called.
  • test_bound_decoder_reads_an_unread_response plus the sync and async client tests failed with DecodeError: ... without having called read().

Also added: self-referential Struct accepted and decoded, repr-collision guard, unkeyable model, and decode on a rejected model raising the library's own error. Mutation checks: dropping the equality guard fails the collision test, and keying generics by the model alone fails both union tests.

just lint, just lint-ci and just test pass: 1029 tests, 100% coverage.

- 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.
@lesnik512
lesnik512 merged commit 75f8544 into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the fix/decoder-fixes branch October 17, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant