Skip to content

Commit 5ecb929

Browse files
authored
Merge pull request #45 from modern-python/docs/fold-perf-into-0.9.1
docs(release): fold can_decode memoization into 0.9.1 (drop premature 0.9.2)
2 parents ef9cbb5 + e56cf9a commit 5ecb929

2 files changed

Lines changed: 20 additions & 19 deletions

File tree

planning/releases/0.9.1.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
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
22

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.
44

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
610

711
`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.
812

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

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.
1226

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
1428

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.

planning/releases/0.9.2.md

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)