Skip to content

Commit 6f32ca9

Browse files
authored
fix: bound the decoder adapter caches (#191)
* fix: bound the decoder adapter caches Each decoder cache now holds at most 4096 models and is emptied when an insert would go past that, so models created per request no longer pile up for the life of the decoder. * docs: middleware response streams must match the client * test: check only our cache for dropped msgspec models Older msgspec builds typing.Tuple[...] while inspecting a model, and typing's subscription LRU keeps the recent models alive, so the weakref check failed on the dependency floors.
1 parent 89553a6 commit 6f32ca9

3 files changed

Lines changed: 98 additions & 4 deletions

File tree

‎docs/decoders.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ Both clients take `decoders: Sequence[ResponseDecoder] | None = None`. The list
3535
- If `response_model=` is set and no decoder accepts it, the client raises `MissingDecoderError` before sending the request. That means nothing handles the type, and the fix is to install an extra or pass `decoders=[...]`. A `DecodeError` instead means a decoder ran and the body didn't fit the type, which points at the server or the model. See [Errors](errors.md).
3636
- `MsgspecDecoder` accepts a `Struct`, dataclass, `TypedDict` or `NamedTuple` only if msgspec can decode every field type itself, nested fields included. A field typed as a pydantic model or some other class would need a `dec_hook`, which `MsgspecDecoder` doesn't take, so it declines the model and you get `MissingDecoderError` before the request goes out.
3737

38+
## Keep response models long-lived
39+
40+
The built-in decoders build a parser once per model (a `pydantic.TypeAdapter` or a `msgspec.json.Decoder`) and cache it, so define each response model once, at module level, and reuse it. Each decoder caches up to 4,096 models. When the cache is full, the next new model empties it. A model created per request, for example with `pydantic.create_model` or `msgspec.defstruct`, takes a new slot every time, so the cache keeps filling up and emptying, and every model seen after it empties gets its parser built again.
41+
3842
## One sync protocol for both clients
3943

40-
Middleware comes in sync and async versions, but there is only one `ResponseDecoder` protocol, used by both clients. `decode` is synchronous because the client has the whole body by the time it runs, so there is nothing to await. If a middleware returns a streamed response it never read, the client reads it before decoding. In `AsyncClient` that stream also has to support sync iteration, as `httpx2.ByteStream` does; otherwise the middleware should `await response.aread()` before returning it. The same decoder works with either client.
44+
Middleware comes in sync and async versions, but there is only one `ResponseDecoder` protocol, used by both clients. `decode` is synchronous because the client has the whole body by the time it runs, so there is nothing to await. If a middleware returns a streamed response it never read, the client reads it before decoding. The stream has to match the client: async in `AsyncClient` and sync in `Client`, or the call raises `TypeError` (see [Middleware](middleware.md)). An in-memory body such as `content=b"..."` works in both. The same decoder works with either client.
4145

4246
## Writing your own
4347

‎src/httpware/decoders/_caching.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
V = typing.TypeVar("V")
1212

13+
_MAX_ENTRIES = 4096
14+
1315
_Cache: typing.TypeAlias = dict[Hashable, tuple[object, V | None]]
1416

1517

@@ -28,14 +30,15 @@ def _lookup(cache: "_Cache[V]", model: object, build: Callable[[typing.Any], V |
2830
"""Return the cached `build(model)`, building it on a miss; None means `model` cannot be decoded.
2931
3032
`build` must not raise. Concurrent first calls may build twice; the last write wins.
33+
A full cache is emptied before the next insert, so a model seen again after that is rebuilt.
3134
"""
3235
try:
3336
key = _key(model)
3437
entry = cache.get(key)
3538
except Exception: # noqa: BLE001 — unhashable models fall back to a repr key
3639
return _lookup_by_repr(cache, model, build)
3740
if entry is None:
38-
entry = cache[key] = (model, build(model))
41+
entry = _store(cache, key, model, build)
3942
return entry[1]
4043

4144

@@ -48,5 +51,15 @@ def _lookup_by_repr(cache: "_Cache[V]", model: object, build: Callable[[typing.A
4851
return entry[1]
4952
except Exception: # noqa: BLE001 — a model that cannot be keyed is built uncached
5053
return build(model)
51-
entry = cache[key] = (model, build(model))
52-
return entry[1]
54+
return _store(cache, key, model, build)[1]
55+
56+
57+
def _store(
58+
cache: "_Cache[V]", key: Hashable, model: object, build: Callable[[typing.Any], V | None]
59+
) -> tuple[object, V | None]:
60+
"""Build and cache the entry for `model`, emptying `cache` first once it holds `_MAX_ENTRIES`."""
61+
entry = (model, build(model))
62+
if len(cache) >= _MAX_ENTRIES:
63+
cache.clear()
64+
cache[key] = entry
65+
return entry

‎tests/test_decoders_caching.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""The decoder adapter caches stay bounded when models are created per request."""
2+
3+
import concurrent.futures
4+
import gc
5+
import typing
6+
import weakref
7+
8+
import msgspec
9+
import pydantic
10+
import pytest
11+
12+
from httpware.decoders import _caching
13+
from httpware.decoders.msgspec import MsgspecDecoder
14+
from httpware.decoders.pydantic import PydanticDecoder
15+
16+
17+
_CAP = 8
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def _small_cap(monkeypatch: pytest.MonkeyPatch) -> None:
22+
monkeypatch.setattr(_caching, "_MAX_ENTRIES", _CAP)
23+
24+
25+
def _decode_through_fresh_models(decoder: PydanticDecoder | MsgspecDecoder, models: typing.Iterator[type]) -> list:
26+
refs = []
27+
for model in models:
28+
decoder.decode(b'{"x": 1}', model)
29+
refs.append(weakref.ref(model))
30+
gc.collect()
31+
return refs
32+
33+
34+
def test_pydantic_cache_is_bounded_and_releases_old_models() -> None:
35+
decoder = PydanticDecoder()
36+
models = (pydantic.create_model(f"M{i}", x=(int, ...)) for i in range(_CAP + 1))
37+
refs = _decode_through_fresh_models(decoder, models)
38+
assert len(decoder._adapters) <= _CAP # noqa: SLF001
39+
assert refs[0]() is None
40+
41+
42+
def test_msgspec_cache_is_bounded_and_drops_old_models() -> None:
43+
"""Checks our cache only: older msgspec inspects through `typing.Tuple[...]`, whose LRU keeps recent models."""
44+
decoder = MsgspecDecoder()
45+
models = [msgspec.defstruct(f"S{i}", [("x", int)]) for i in range(_CAP + 1)]
46+
for model in models:
47+
decoder.decode(b'{"x": 1}', model)
48+
assert len(decoder._adapters) <= _CAP # noqa: SLF001
49+
assert all(cached is not models[0] for cached, _ in decoder._adapters.values()) # noqa: SLF001
50+
51+
52+
def test_repr_keyed_cache_is_bounded() -> None:
53+
decoder = PydanticDecoder()
54+
for i in range(_CAP + 1):
55+
decoder.decode(b"1", typing.Annotated[int, [i]]) # ty: ignore[invalid-argument-type]
56+
assert len(decoder._adapters) <= _CAP # noqa: SLF001
57+
58+
59+
def test_model_decodes_after_the_cache_is_cleared() -> None:
60+
decoder = PydanticDecoder()
61+
for i in range(_CAP + 1):
62+
decoder.decode(b'{"x": 1}', pydantic.create_model(f"M{i}", x=(int, ...)))
63+
model = pydantic.create_model("Late", x=(int, ...))
64+
assert decoder.decode(b'{"x": 2}', model).model_dump() == {"x": 2}
65+
assert decoder.can_decode(model)
66+
67+
68+
@pytest.mark.stress
69+
def test_clear_racing_with_lookups_returns_the_right_adapter() -> None:
70+
models = [type(f"T{i}", (), {}) for i in range(_CAP * 4)]
71+
cache: _caching._Cache[type] = {}
72+
73+
def look_up_all(_: int) -> bool:
74+
return all(_caching._lookup(cache, model, lambda m: m) is model for _ in range(50) for model in models) # noqa: SLF001
75+
76+
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as pool:
77+
assert all(pool.map(look_up_all, range(32)))

0 commit comments

Comments
 (0)