Skip to content

Commit ef9cbb5

Browse files
authored
Merge pull request #44 from modern-python/perf/can-decode-result-cache
perf(decoders): memoize can_decode verdict per model (0.9.2)
2 parents 4b52e15 + b19d545 commit ef9cbb5

5 files changed

Lines changed: 115 additions & 2 deletions

File tree

planning/releases/0.9.2.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# httpware 0.9.2 — decoder `can_decode` verdicts are memoized
2+
3+
**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.
4+
5+
## The gap
6+
7+
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.
8+
9+
## The fix
10+
11+
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.
12+
13+
No public-API change: `can_decode`'s signature, the `ResponseDecoder` protocol, and every routing verdict are unchanged — only repeated computation is removed.

src/httpware/decoders/msgspec.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ class MsgspecDecoder:
5454
"""
5555

5656
_msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
57+
_can_decode_results: dict[type, bool]
5758

5859
def __init__(self) -> None:
5960
if not import_checker.is_msgspec_installed:
6061
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
6162
self._msgspec_decoders = {}
63+
self._can_decode_results = {}
6264

6365
def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
6466
decoder = self._msgspec_decoders.get(model)
@@ -70,6 +72,23 @@ def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
7072
def can_decode(self, model: type) -> bool:
7173
"""Return True iff msgspec natively understands `model` end-to-end.
7274
75+
The verdict is memoized per `model`: the probe below (an uncached
76+
`type_info` call plus a recursive tree walk) runs once per type, not on
77+
every dispatch. Unhashable models skip the cache and probe fresh.
78+
"""
79+
try:
80+
cached = self._can_decode_results.get(model)
81+
except TypeError: # unhashable model — can't memoize, probe fresh
82+
return self._probe_can_decode(model)
83+
if cached is not None:
84+
return cached
85+
result = self._probe_can_decode(model)
86+
self._can_decode_results[model] = result
87+
return result
88+
89+
def _probe_can_decode(self, model: type) -> bool:
90+
"""Decide whether msgspec natively decodes `model` (the uncached path).
91+
7392
msgspec builds a Decoder for almost any class via a generic CustomType
7493
fallback; the Decoder constructor does NOT raise on unsupported types
7594
(e.g. pydantic.BaseModel, or a container parameterized by one). We walk

src/httpware/decoders/pydantic.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ class PydanticDecoder:
2626
"""Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`."""
2727

2828
_adapters: dict[type, TypeAdapter[typing.Any]]
29+
_can_decode_results: dict[type, bool]
2930

3031
def __init__(self) -> None:
3132
if not import_checker.is_pydantic_installed:
3233
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
3334
self._adapters = {}
35+
self._can_decode_results = {}
3436

3537
def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]":
3638
adapter = self._adapters.get(model)
@@ -42,6 +44,23 @@ def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]":
4244
def can_decode(self, model: type) -> bool:
4345
"""Return True iff pydantic can build a schema for `model`.
4446
47+
The verdict is memoized per `model` so a rejection (which costs a
48+
`PydanticSchemaGenerationError` round-trip) is not re-probed on every
49+
dispatch. Unhashable models skip the cache and probe fresh.
50+
"""
51+
try:
52+
cached = self._can_decode_results.get(model)
53+
except TypeError: # unhashable model — can't memoize, probe fresh
54+
return self._probe_can_decode(model)
55+
if cached is not None:
56+
return cached
57+
result = self._probe_can_decode(model)
58+
self._can_decode_results[model] = result
59+
return result
60+
61+
def _probe_can_decode(self, model: type) -> bool:
62+
"""Decide whether pydantic can build a schema for `model` (uncached).
63+
4564
Probes via `_get_adapter`; subsequent calls (including `decode`) reuse
4665
the cached `TypeAdapter`. Rejects `msgspec.Struct` subclasses —
4766
pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when

tests/test_decoders_msgspec.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import dataclasses
44
from http import HTTPStatus
5-
from unittest.mock import patch
5+
from unittest.mock import MagicMock, patch
66

77
import httpx2
88
import msgspec
@@ -196,3 +196,37 @@ def test_msgspec_still_accepts_native_containers(model: type) -> None:
196196
plain builtin element types.
197197
"""
198198
assert MsgspecDecoder().can_decode(model) is True
199+
200+
201+
def test_msgspec_can_decode_result_is_cached() -> None:
202+
"""Repeat can_decode calls reuse a cached verdict, not the per-dispatch probe."""
203+
decoder = MsgspecDecoder()
204+
with patch(
205+
"httpware.decoders.msgspec.msgspec.inspect.type_info",
206+
wraps=msgspec.inspect.type_info,
207+
) as spy:
208+
assert decoder.can_decode(_Item) is True
209+
assert decoder.can_decode(_Item) is True
210+
assert spy.call_count == 1
211+
assert decoder._can_decode_results[_Item] is True # noqa: SLF001
212+
213+
214+
def test_msgspec_can_decode_caches_negative_verdict() -> None:
215+
"""A rejection is cached too, so repeat probes don't repeat the walk."""
216+
decoder = MsgspecDecoder()
217+
with patch(
218+
"httpware.decoders.msgspec.msgspec.inspect.type_info",
219+
wraps=msgspec.inspect.type_info,
220+
) as spy:
221+
assert decoder.can_decode(_PydanticUser) is False
222+
assert decoder.can_decode(_PydanticUser) is False
223+
assert spy.call_count == 1
224+
assert decoder._can_decode_results[_PydanticUser] is False # noqa: SLF001
225+
226+
227+
def test_msgspec_can_decode_unhashable_model_does_not_raise() -> None:
228+
"""An unhashable model falls back to a fresh probe instead of raising from the cache."""
229+
decoder = MsgspecDecoder()
230+
decoder._can_decode_results = MagicMock() # noqa: SLF001
231+
decoder._can_decode_results.get.side_effect = TypeError("unhashable type") # noqa: SLF001
232+
assert decoder.can_decode(_Item) is True

tests/test_decoders_pydantic.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
import concurrent.futures
55
import dataclasses
6-
from unittest.mock import patch
6+
from unittest.mock import MagicMock, patch
77

88
import msgspec
99
import pydantic
@@ -211,3 +211,31 @@ def test_pydantic_can_decode_uses_cache() -> None:
211211
decoder.can_decode(User)
212212
assert len(decoder._adapters) == 1 # noqa: SLF001
213213
assert User in decoder._adapters # noqa: SLF001
214+
215+
216+
def test_pydantic_can_decode_result_is_cached() -> None:
217+
"""Repeat can_decode calls reuse a cached verdict, not the per-dispatch probe."""
218+
decoder = PydanticDecoder()
219+
with patch.object(decoder, "_get_adapter", wraps=decoder._get_adapter) as spy: # noqa: SLF001
220+
assert decoder.can_decode(User) is True
221+
assert decoder.can_decode(User) is True
222+
assert spy.call_count == 1
223+
assert decoder._can_decode_results[User] is True # noqa: SLF001
224+
225+
226+
def test_pydantic_can_decode_caches_negative_verdict() -> None:
227+
"""A rejection is cached too, so repeat probes skip the schema-build round-trip."""
228+
decoder = PydanticDecoder()
229+
with patch.object(decoder, "_get_adapter", wraps=decoder._get_adapter) as spy: # noqa: SLF001
230+
assert decoder.can_decode(_Struct) is False
231+
assert decoder.can_decode(_Struct) is False
232+
assert spy.call_count == 1
233+
assert decoder._can_decode_results[_Struct] is False # noqa: SLF001
234+
235+
236+
def test_pydantic_can_decode_unhashable_model_does_not_raise() -> None:
237+
"""An unhashable model falls back to a fresh probe instead of raising from the cache."""
238+
decoder = PydanticDecoder()
239+
decoder._can_decode_results = MagicMock() # noqa: SLF001
240+
decoder._can_decode_results.get.side_effect = TypeError("unhashable type") # noqa: SLF001
241+
assert decoder.can_decode(User) is True

0 commit comments

Comments
 (0)