Repository navigation
fix: decoder cache keys, msgspec field claims, unread middleware responses - #168
Merged
Merged
Conversation
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Union order in the cache key
PydanticDecoderandMsgspecDecodercached per model with plain dict lookup, andA | B == B | Ahashes and compares equal. OnceA | Bwas decoded, a laterB | Agot the sameTypeAdapter. Pydantic's smart union breaks ties by member order, so the second call returned anAwhere it should have returned aB. The same applied inside generics (list[A | B]vslist[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), andNonemeans "cannot decode".can_decodeanddecoderead the same entry._caching.pynow holds the key function and the lookup. Thread safety is the same as before: a singledict.getand 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 newTypeAdapterormsgspec.json.Decoder(twice: once incan_decode, once indecode). 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, socan_decodestill never raises.msgspec over-claiming
MsgspecDecoder.can_decodelooked for msgspec'sCustomTypefallback in container parameters but stopped at aStruct, dataclass,TypedDictorNamedTuple. AStructwith 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 andAnnotated, 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 raisesMissingDecoderErrorbefore the request, as ADR-0005 intends. The unreachableis_msgspec_installedcheck inside the walk is gone.Unread middleware responses
_BoundDecoder.decodepassedresponse.contentto the decoder. A middleware that returns a streamed response it never read made that raiseResponseNotRead, which surfaced asDecodeError(original=ResponseNotRead). It now callsresponse.read(), which returns the cached body when it is already read. I put the read inside theDecodeErrorwrap.can_decodestill runs inresolve(), outside the wrap. If the read sat outside the wrap, a stream failure would escape as a rawhttpx2/RuntimeErrorinstead of aClientError.Behaviour changes for 0.22.0
MsgspecDecoderno longer claims aStruct, dataclass,TypedDictorNamedTuplewith a field msgspec cannot decode without adec_hook(a pydantic model, an arbitrary class). These requests now fail withMissingDecoderErrorbefore sending. They used to send and then fail withDecodeError. With the default list (pydantic first), a dataclass orTypedDictlike that still goes toPydanticDecoderas before.B | Adecodes with its own adapter afterA | Bhas been seen, so pydantic's member-order tie-break now applies per call site.AsyncClientthe read is synchronous, so a stream that only supports async iteration (an async generator passed ascontent=) still ends inDecodeError(original=RuntimeError). Fixing that needsAsyncClient.send/send_with_responseinclient.pytoawait response.aread()beforebound.decode(response), andclient.pyis outside this PR.httpx2.ByteStream, which supports both, works in either client. docs/decoders.md describes this.PydanticDecoder._can_decode_results,_get_adapter,MsgspecDecoder._msgspec_decoders,_can_decode_results,_get_msgspec_decoder,msgspec._contains_custom_typeand_caching._get_or_buildare 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:can_decodecan_decodecan_decode+decodecan_decode+decodeP(pydantic class)S(msgspec Struct)list[P]P | Nonelist[S]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
sendlooks up twice (can_decodeinresolve, thendecode). 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_keyandtest_nested_union_member_order_is_part_of_the_cache_keyfailed withassert <class '_Left'> is _Right.test_unhashable_model_builds_adapter_once(pydantic) andtest_unhashable_model_builds_decoder_once(msgspec) failed withassert 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 withassert True is False. The sync and async client tests failed because the transport was called.test_bound_decoder_reads_an_unread_responseplus the sync and async client tests failed withDecodeError: ... without having called read().Also added: self-referential
Structaccepted and decoded, repr-collision guard, unkeyable model, anddecodeon 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-ciandjust testpass: 1029 tests, 100% coverage.