Skip to content

0.9.1 — nested-CustomType fix + can_decode memoization

Choose a tag to compare

@lesnik512 lesnik512 released this 13 Jun 08:56
6d73b43

httpware 0.9.1 — decoder dispatch: nested-CustomType fix + can_decode memoization

Patch release bundling two decoder-dispatch fixes — one correctness, one performance. No public-API change: can_decode's signature, the ResponseDecoder protocol, and every routing verdict are unchanged.

1. MsgspecDecoder stops claiming containers it can't decode (correctness)

One behavior change. When MsgspecDecoder is the only decoder registered (an msgspec-only install, or an explicit decoders=[MsgspecDecoder()]), a response_model= of list[SomePydanticModel], dict[str, SomePydanticModel], SomePydanticModel | None, or any container parameterized by a type msgspec can't natively decode now raises MissingDecoderError before a request is sent — instead of sending the request and failing at decode with DecodeError.

The gap

MsgspecDecoder.can_decode answers the client's pre-flight question "can you decode this type?" — and on a False from every registered decoder, the client raises MissingDecoderError without touching the network. msgspec builds a json.Decoder for almost any type via a generic CustomType fallback, so can_decode used msgspec.inspect.type_info to detect and reject that fallback. But it inspected only the top-level node: type_info(list[PUser]) is a ListType whose item_type is the CustomType, so the top-level check passed, the decoder built, and can_decode returned True. The pre-flight was bypassed, a real HTTP request went out, and decode then raised a validation error (surfaced as DecodeError). The false-positive was cached per instance, so every later request of that shape repeated the wasted round-trip.

Under the default pydantic-first decoders=[PydanticDecoder(), MsgspecDecoder()], this was masked — pydantic claims list[PUser] first. The bug only bit msgspec-only configurations.

The fix

can_decode now walks the full type_info tree and rejects if a CustomType appears anywhere in it, via a recursive helper that visits every nested element type (list/dict/set/tuple/Optional/Union, arbitrarily nested). The walk stops at Struct/dataclass field boundaries automatically, so genuine msgspec targets like list[SomeStruct] stay accepted and self-referential structs can't loop. Only the set of types MsgspecDecoder claims is corrected.

2. can_decode verdicts are memoized (performance)

No behavior change — a performance fix. Both MsgspecDecoder.can_decode and PydanticDecoder.can_decode now cache their per-model verdict, so the type probe runs once per response_model type instead of on every request.

The gap

The client consults decoder.can_decode(model) on every dispatch to route a response_model= to the right decoder. The recursive type_info walk added in fix #1 above is not free — roughly 30 µs per call for a type like list[SomeStruct] (an uncached msgspec.inspect.type_info() plus the tree walk). PydanticDecoder.can_decode had the milder version of the same shape: it re-probed TypeAdapter construction every call and never cached a rejection. Since a client decodes the same handful of model types over and over, that cost was paid on every single request.

The fix

Both decoders gain a per-instance dict[type, bool] verdict cache. can_decode returns the memoized result when present and only runs the probe on the first sighting of a model type. Repeat calls drop from ~30 µs to ~0.15 µs (msgspec) / ~0.07 µs (pydantic) — a ~200× reduction on the hot path. Unhashable models skip the cache and probe fresh, preserving the existing fallback behavior. The cache is bounded by the set of response_model types an application actually uses, mirroring the existing per-instance decoder/adapter caches.