diff --git a/planning/releases/0.9.1.md b/planning/releases/0.9.1.md index 04c97f6..d1caa74 100644 --- a/planning/releases/0.9.1.md +++ b/planning/releases/0.9.1.md @@ -1,15 +1,29 @@ -# httpware 0.9.1 — `MsgspecDecoder` stops claiming containers it can't decode +# httpware 0.9.1 — decoder dispatch: nested-`CustomType` fix + `can_decode` memoization -**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`. +**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. -## The gap +## 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 +### 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. -`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. +### The fix -No public-API change: the `ResponseDecoder` protocol and `can_decode`'s signature are unchanged. Only the set of types `MsgspecDecoder` claims is corrected. +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. diff --git a/planning/releases/0.9.2.md b/planning/releases/0.9.2.md deleted file mode 100644 index 69ecc12..0000000 --- a/planning/releases/0.9.2.md +++ /dev/null @@ -1,13 +0,0 @@ -# httpware 0.9.2 — decoder `can_decode` verdicts are memoized - -**Patch release, no behavior change — a performance fix.** `MsgspecDecoder.can_decode` and `PydanticDecoder.can_decode` now cache their per-model verdict, so the expensive 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. After the 0.9.1 nested-`CustomType` fix, `MsgspecDecoder.can_decode` ran an uncached `msgspec.inspect.type_info()` call plus a recursive tree walk on each invocation — roughly **30 µs per call** for a type like `list[SomeStruct]`. `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. - -No public-API change: `can_decode`'s signature, the `ResponseDecoder` protocol, and every routing verdict are unchanged — only repeated computation is removed.