|
1 | | -# httpware 0.9.1 — `MsgspecDecoder` stops claiming containers it can't decode |
| 1 | +# httpware 0.9.1 — decoder dispatch: nested-`CustomType` fix + `can_decode` memoization |
2 | 2 |
|
3 | | -**Patch release with 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`. |
| 3 | +**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. |
4 | 4 |
|
5 | | -## The gap |
| 5 | +## 1. `MsgspecDecoder` stops claiming containers it can't decode (correctness) |
| 6 | + |
| 7 | +**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`. |
| 8 | + |
| 9 | +### The gap |
6 | 10 |
|
7 | 11 | `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. |
8 | 12 |
|
9 | 13 | Under the default pydantic-first `decoders=[PydanticDecoder(), MsgspecDecoder()]`, this was masked — pydantic claims `list[PUser]` first. The bug only bit msgspec-only configurations. |
10 | 14 |
|
11 | | -## The fix |
| 15 | +### The fix |
| 16 | + |
| 17 | +`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. |
| 18 | + |
| 19 | +## 2. `can_decode` verdicts are memoized (performance) |
| 20 | + |
| 21 | +**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. |
| 22 | + |
| 23 | +### The gap |
| 24 | + |
| 25 | +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. |
12 | 26 |
|
13 | | -`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. |
| 27 | +### The fix |
14 | 28 |
|
15 | | -No public-API change: the `ResponseDecoder` protocol and `can_decode`'s signature are unchanged. Only the set of types `MsgspecDecoder` claims is corrected. |
| 29 | +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. |
0 commit comments