From 4ad869d3c6c5b17e61b7027fab51c3d5bc29a8ed Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 13:07:16 +0300 Subject: [PATCH] feat(story-1.5): ResponseDecoder protocol and pydantic adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the AsyncClient ↔ ResponseDecoder seam (Seam 3): - `ResponseDecoder` runtime-checkable Protocol with `decode(content, model)`. - `PydanticDecoder` adapter using `pydantic.TypeAdapter.validate_json(bytes)` for a single-parse-pass; a module-level `lru_cache` factory memoizes one `TypeAdapter` per `response_model` across the process. - Public exports: `ResponseDecoder`, `PydanticDecoder`. - Performance tests gated behind `-m perf` (skipped by default; opt-in via `pytest -m perf`) with a `perf` marker registered in `pyproject.toml`. - Code-review deferrals for empty/malformed payloads, PLR2004 per-file-ignore consolidation, CHANGELOG tone, and runtime_checkable isinstance cost recorded in docs/deferred-work.md. - README adds the modern-python org pointer. Sprint status: 1-5 → done; epic-1 still in-progress (1-6, 1-7, 1-8 remain). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + README.md | 6 + docs/deferred-work.md | 7 + ...sedecoder-protocol-and-pydantic-adapter.md | 407 ++++++++++++++++++ docs/stories/sprint-status.yaml | 93 ++++ pyproject.toml | 5 +- src/httpware/__init__.py | 4 + src/httpware/decoders/__init__.py | 18 + src/httpware/decoders/pydantic.py | 29 ++ tests/test_decoders_pydantic.py | 156 +++++++ tests/test_decoders_pydantic_bench.py | 110 +++++ 11 files changed, 835 insertions(+), 1 deletion(-) create mode 100644 docs/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md create mode 100644 docs/stories/sprint-status.yaml create mode 100644 src/httpware/decoders/__init__.py create mode 100644 src/httpware/decoders/pydantic.py create mode 100644 tests/test_decoders_pydantic.py create mode 100644 tests/test_decoders_pydantic_bench.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d897a..08d7431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,5 +18,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Core data types: `Request`, `Response`, `Limits`, `Timeout`, `ClientConfig` — frozen+slotted dataclasses with `with_*` immutability helpers on `Request` and computed `text`/`json()` accessors on `Response` (Story 1.2). - Status-keyed exception hierarchy with plain typed fields: `ClientError`, `TransportError`, `TimeoutError`, `StatusError`, `ClientStatusError`/`ServerStatusError` bases, 9 leaf classes (`BadRequestError` … `ServiceUnavailableError`), `STATUS_TO_EXCEPTION` lookup dict (Story 1.3). `StatusError` is picklable and deep-copyable via custom `__reduce__`; `__repr__` and the summary message strip `user:pass@` userinfo from the request URL; `headers` is stored as a read-only `MappingProxyType` so caller mutations after `raise` do not bleed into the exception. `TimeoutError` multi-inherits from `builtins.TimeoutError` (revisits architecture Decision 3) so `except builtins.TimeoutError` (the form `asyncio.wait_for` raises) also catches httpware-raised timeouts. - `Transport` protocol (`@runtime_checkable`) and default `Httpx2Transport` adapter; `StreamResponse` placeholder for Story 4.1 protocol typing; the wire `method` is uppercased at the seam and `httpx2` exceptions (`TimeoutException`, `HTTPError`, `InvalidURL`, `CookieConflict`, and the closed-client `RuntimeError`) are mapped to `httpware.TimeoutError` / `httpware.TransportError` (with the original exception's message preserved on the mapped instance) so no `httpx2` exception escapes the library; lazy `httpx2.AsyncClient` construction is guarded by an `asyncio.Lock` so concurrent first-calls share one client; `httpx2` is confined to `src/httpware/transports/httpx2.py` (Story 1.4). +- `ResponseDecoder` protocol (`@runtime_checkable`) and default `PydanticDecoder` adapter — single-parse-pass JSON decoding via `pydantic.TypeAdapter.validate_json(bytes)`; a module-level `@functools.lru_cache(maxsize=None)` factory (`_get_adapter`) memoizes one `TypeAdapter` per `response_model` across the process so warm-path requests pay zero adapter-construction cost; `pydantic.ValidationError` surfaces unchanged to the caller (Story 1.5). [Unreleased]: https://github.com/modern-python/httpware/commits/main diff --git a/README.md b/README.md index 172e8ef..5deb381 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ async def main() -> None: Full docs (in progress): https://httpware.readthedocs.io +## Part of `modern-python` + +Browse the full list of templates and libraries in +[`modern-python`](https://github.com/modern-python) — see the org profile for the +categorized index. + ## License MIT — see [LICENSE](./LICENSE). diff --git a/docs/deferred-work.md b/docs/deferred-work.md index 37b6684..fe5fe35 100644 --- a/docs/deferred-work.md +++ b/docs/deferred-work.md @@ -2,6 +2,13 @@ Items raised in reviews that are real but not actionable now. +## Deferred from: code review of story-1-5 (2026-05-14) + +- **Empty/malformed payload tests** — `b""`, `b"null"`, `b"{}"`, invalid UTF-8: current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change error types undetected. (`tests/test_decoders_pydantic.py`) +- **`PLR2004` per-file-ignores** — `# noqa: PLR2004` repeated 4× in this test file; idiomatic fix is `tool.ruff.lint.per-file-ignores` for `tests/*`. Project-wide lint-config tidy. (`tests/test_decoders_pydantic.py:48,57,68,82`) +- **CHANGELOG bullet tone** — leaks `_get_adapter` / "zero adapter-construction cost" implementation detail into a user-facing log; AC14 has no wording constraint. Pre-v1 tone pass. (`CHANGELOG.md:19`) +- **`@runtime_checkable` isinstance cost in Story 1.7** — `_ProtocolMeta.__instancecheck__` is ~µs-scale; matters only if `AsyncClient(decoder=...)` validation runs per-request rather than per-construction. Defer to Story 1.7 design. + ## Deferred from: code review of story-1-4 (2026-05-14) - **Unbounded error body size** — `StatusError.body` holds the full `resp.content` with no cap; large 5xx pages stay pinned in memory through exception lifetimes (Sentry payloads, logs, retained tracebacks). Revisit with retry/observability middleware. (`src/httpware/transports/httpx2.py:117-124`) diff --git a/docs/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md b/docs/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md new file mode 100644 index 0000000..dfb5549 --- /dev/null +++ b/docs/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md @@ -0,0 +1,407 @@ +--- +story_key: 1-5-responsedecoder-protocol-and-pydantic-adapter +epic: 1 +story: 5 +title: ResponseDecoder protocol and pydantic adapter +status: done +created: 2026-05-14 +input_documents: + - docs/prd.md + - docs/architecture.md + - docs/epics.md + - docs/stories/1-2-core-data-types.md + - docs/stories/1-3-exception-hierarchy-with-plain-fields.md + - docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md + - docs/deferred-work.md +--- + +# Story 1.5: ResponseDecoder protocol and pydantic adapter + +## Status + +`done` + +## Story + +**As a** consumer developer, +**I want** to decode response bodies into pydantic models in a single parse pass, +**So that** `response_model=User` returns a typed `User` with minimal overhead. + +## Acceptance Criteria + +**AC1.** **Given** the `Response` type from Story 1.2 and the project scaffold, **When** I implement `src/httpware/decoders/__init__.py`, **Then** the module defines a `ResponseDecoder` class decorated with `@runtime_checkable` and inheriting `Protocol` (from `typing`), with **exactly** this one method signature and no other public attributes: + +```python +def decode(self, content: bytes, model: type[T]) -> T: ... +``` + +`T` is a module-level `TypeVar("T")`. The protocol is NOT parameterized at the class level (`Protocol`, not `Protocol[T]`) — `T` is scoped per-call so a single decoder instance decodes into many model types. `Protocol` and `runtime_checkable` are imported from `typing`. The module's `__all__` is `["ResponseDecoder"]`. The module docstring is one short line identifying the protocol as Seam 3 (`AsyncClient ↔ ResponseDecoder`) per architecture lines 703–708. + +**AC2.** **And** I implement `src/httpware/decoders/pydantic.py` with a class `PydanticDecoder` whose `__init__` takes no arguments (defining `__init__` explicitly is optional — the implicit `object.__init__` is acceptable). Instances are stateless: all callable state is in the module-level cache (AC3). `PydanticDecoder` **does not** inherit from `ResponseDecoder` — structural subtyping via `@runtime_checkable` handles conformance (same pattern Story 1.4 used for `Httpx2Transport`; architecture line 282). The module's `__all__` is `["PydanticDecoder"]`. The module docstring is one short line. + +**AC3.** **And** `src/httpware/decoders/pydantic.py` defines a **module-level** function `_get_adapter` with this signature and decoration: + +```python +@functools.lru_cache(maxsize=1024) +def _get_adapter(model: type[T]) -> pydantic.TypeAdapter[T]: + return pydantic.TypeAdapter(model) +``` + +The cache is module-scope (shared across all `PydanticDecoder` instances in the process). `model` is the cache key; per pydantic's reuse guidance ("create a `TypeAdapter` for a given type just once and reuse it"; https://docs.pydantic.dev/latest/concepts/performance/#avoid-creating-instances-of-typeadapter-in-loops). `maxsize=1024` is a soft upper bound that protects long-running services from unbounded growth if a consumer dynamically generates models (e.g., `pydantic.create_model(...)` per request — see Open Questions item 3). The bound is far above any realistic static schema universe. `_get_adapter` is private (leading underscore) and NOT in `__all__`. + +**Review-driven amendment (2026-05-14):** AC3 originally specified `maxsize=None`. Relaxed to `maxsize=1024` per review Decision 1, resolving Open Questions item 3. + +**AC4.** **And** `PydanticDecoder.decode(self, content, model)` has exactly this body: + +```python +try: + adapter = _get_adapter(model) +except TypeError: + adapter = TypeAdapter(model) +return adapter.validate_json(content) +``` + +The `try/except TypeError` fallback handles unhashable `model` arguments (e.g., `Annotated[int, unhashable_metadata]`) — without it, `functools.lru_cache`'s hash step would leak a `TypeError` from a `functools`-internal frame, breaking the "validation errors surface as `pydantic.ValidationError`" contract. The fallback bypasses the cache for that single call only; cached-path performance for the common hashable case is unaffected. No intermediate `json.loads`, no `validate_python`, no `BaseModel.model_validate_json` — a single `validate_json(bytes)` call (NFR3: single parse pass). The method does not catch `pydantic.ValidationError` — it surfaces unchanged to the caller. The method does not validate `content` is `bytes` at runtime. + +**Review-driven amendment (2026-05-14):** AC4 originally required `return _get_adapter(model).validate_json(content)` as the only body. The `try/except TypeError` fallback was added per review Decision 5. + +**AC5.** **And** `pydantic` is imported at the top of `src/httpware/decoders/pydantic.py` as a plain top-level import — **no `try/except ImportError`** is needed because `pydantic>=2.0,<3.0` is a **base** dependency (`pyproject.toml` line 32), not an optional extra. The architecture's optional-extra import pattern (lines 509–530) applies only to extras-gated modules (msgspec → Story 1.6, otel → Epic 5). Importing the third-party `pydantic` package from a module named `pydantic.py` is unambiguous in Python 3 (absolute imports walk `sys.path`, never the current package); use `from pydantic import TypeAdapter` for readability. + +**AC6.** **And** I extend `src/httpware/__init__.py` to re-export `ResponseDecoder` and `PydanticDecoder` from their respective submodules, growing `__all__` from 24 entries (Story 1.4 close-out) to **26** entries. The RUF022-sorted order places `PydanticDecoder` between `NotFoundError` and `RateLimitedError`, and `ResponseDecoder` between `Response` and `ServerStatusError`. Trust `ruff check --select RUF022 --fix` to produce the canonical order. + +**AC7.** **And** unit tests in `tests/test_decoders_pydantic.py` verify successful decode for the five type categories the epic enumerates (each a separate test or a parametrized case): + +| Target type | Sample input bytes | Expected return | +|---|---|---| +| `pydantic.BaseModel` subclass | `b'{"id": 1, "name": "Ada"}'` | instance of the model with `.id == 1`, `.name == "Ada"` | +| stdlib `@dataclasses.dataclass` | `b'{"id": 1, "name": "Ada"}'` | dataclass instance with `id == 1`, `name == "Ada"` | +| `list[User]` (User = BaseModel) | `b'[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}]'` | `list` of length 2, both `User` instances | +| `dict[str, User]` | `b'{"u1": {"id": 1, "name": "Ada"}}'` | `dict` with one `User` value | +| primitive `int` | `b'42'` | `42` (`type(result) is int`) | + +For each, assert exact type identity and field equality. Use plain stdlib `@dataclasses.dataclass` (frozen=False is fine; pydantic accepts both `dataclasses.dataclass` and `pydantic.dataclasses.dataclass`). + +**AC8.** **And** a cache-invariance test verifies the NFR2 contract: `_get_adapter` constructs **exactly one** `pydantic.TypeAdapter` instance per `model` argument across 1000 calls with the same `model`. Implementation guidance: monkeypatch (or `unittest.mock.patch`) `pydantic.TypeAdapter` (or `httpware.decoders.pydantic.pydantic.TypeAdapter` — whichever import shape was used) inside the test, clear the cache with `_get_adapter.cache_clear()` in test setup, perform 1000 `PydanticDecoder().decode(...)` calls (or 1000 direct `_get_adapter(SameModel)` calls), and assert the patched constructor was called exactly once. Also assert that two distinct model types in the same loop trigger exactly two constructor calls. + +**AC9.** **And** a benchmark test in `tests/test_decoders_pydantic_bench.py` confirms the NFR3 contract: `PydanticDecoder().decode(content, User)` is **≥1.5× faster** than `pydantic.TypeAdapter(User).validate_python(json.loads(content))` on a 5KB JSON payload, measured via a median-of-60-rounds `time.perf_counter_ns` loop with GC disabled. The benchmark fixture builds a 5KB payload (`4500 <= len(payload) <= 5500`). The ratio is asserted in the test (`SPEEDUP_FLOOR = 1.5`) so a regression fails CI. The assertive timing test carries `@pytest.mark.perf` and is skipped by default `pytest`; run it with `pytest -m perf` (the `-m 'not perf'` exclusion is in `tool.pytest.ini_options.addopts`). Two complementary `@pytest.mark.benchmark` cases (`test_bench_single_pass_*`, `test_bench_two_pass_*`) remain in the default suite via pytest-benchmark. + +**Review-driven amendment (2026-05-14):** AC9 originally required ≥2×. Relaxed to ≥1.5× per review Decision 2, resolving Open Questions item 5 (observed median ~1.63× on Apple Silicon / pydantic-core 2.46.4; ≥2× not hardware-portable). The assertive timing test was gated behind the `perf` marker per Decision 3 to keep `just test` runs CI-noise-resilient. + +**AC10.** **And** invalid input surfaces `pydantic.ValidationError` unchanged: a test passes malformed JSON (e.g., `b'{"id": "not-a-number"}'` for a `User(id: int, name: str)` model) and asserts `pytest.raises(pydantic.ValidationError)`. **No** httpware-owned exception wraps this — the decoder's contract is "the caller chose the model; the caller catches validation errors". (See Open Questions for the framework-policy tension with FR36.) + +**AC11.** **And** the cache survives concurrent first-calls without constructing extra `TypeAdapter` instances. `functools.lru_cache` is thread-safe (the underlying hashmap is protected by the GIL for ops the cache uses); document this in the test rationale rather than adding an explicit lock. A single async test that schedules 50 concurrent `PydanticDecoder().decode(...)` coroutines against a freshly-cleared cache and asserts the patched `TypeAdapter` constructor is called exactly once suffices. + +**AC12.** **And** `ty check` passes with zero diagnostics. The protocol's generic typing is achievable on Python 3.11 (the project floor): `T = TypeVar("T")` at module scope, `def decode(self, content: bytes, model: type[T]) -> T: ...` on the protocol, and the same signature on `PydanticDecoder.decode`. If `ty` rejects the `Protocol` + per-method `TypeVar` combination, fall back to declaring `class ResponseDecoder(Protocol)` with `decode` as a `Generic[T]`-style method; do **not** make the protocol class-level generic (`Protocol[T]`) — that breaks "one decoder, many models". Confirmation: `ty` on the existing `Transport` protocol (Story 1.4) accepts the structurally-identical signature shape; this AC is a continuation of that pattern. + +**AC13.** **And** `ruff format`, `ruff check`, and `pytest --cov` all pass locally. The decoder modules are explicitly **excluded from the 90% coverage threshold** (NFR23 line 690: "transports and decoders excluded, since both are largely adapter code"), but the new tests should still drive **100% line + branch coverage** on `src/httpware/decoders/__init__.py` and `src/httpware/decoders/pydantic.py` — the surface area is small (~25 LOC combined), so full coverage is cheap and prevents regressions. The CI invariant `grep -rE 'import httpx2|from httpx2' src/httpware/` (Story 1.4) is unaffected — this story adds no `httpx2` references. + +**AC14.** **And** `CHANGELOG.md`'s `Unreleased` → `Added` section gains one new bullet for this story (after Story 1.4's bullet), e.g. "ResponseDecoder protocol and PydanticDecoder adapter (Story 1.5)." Do not reformat existing entries; append only. + +## Tasks/Subtasks + +- [x] **Task 1 — `ResponseDecoder` protocol module** (AC1, AC12) + - [x] Create `src/httpware/decoders/__init__.py` + - [x] Add module docstring (one line, mentions Seam 3) + - [x] Define `T = TypeVar("T")` at module scope + - [x] Define `@runtime_checkable` `class ResponseDecoder(Protocol):` with the single `decode` method + - [x] Set `__all__ = ["ResponseDecoder"]` + - [x] Verify `ty check src/httpware/decoders/__init__.py` is clean + +- [x] **Task 2 — `PydanticDecoder` class** (AC2, AC4, AC5) + - [x] Create `src/httpware/decoders/pydantic.py` + - [x] Add module docstring (one line) + - [x] Top-of-file imports: `import functools`, `from pydantic import TypeAdapter`, `from typing import TypeVar` + - [x] Define module-level `T = TypeVar("T")` + - [x] Define `class PydanticDecoder:` with `decode(self, content: bytes, model: type[T]) -> T` returning `_get_adapter(model).validate_json(content)` + - [x] Set `__all__ = ["PydanticDecoder"]` + +- [x] **Task 3 — `_get_adapter` cached factory** (AC3, AC4) + - [x] In `src/httpware/decoders/pydantic.py`, define `@functools.lru_cache(maxsize=None)` decorated `_get_adapter(model: type[T]) -> TypeAdapter[T]` + - [x] Body: `return TypeAdapter(model)` (single line) + - [x] Place `_get_adapter` above `PydanticDecoder` so the class can reference it + - [x] Leading underscore — NOT in `__all__` + +- [x] **Task 4 — Public re-exports** (AC6) + - [x] Add `from httpware.decoders import ResponseDecoder` to `src/httpware/__init__.py` + - [x] Add `from httpware.decoders.pydantic import PydanticDecoder` to `src/httpware/__init__.py` + - [x] Extend `__all__` to 26 entries (let `ruff check --fix` handle RUF022 sort) + - [x] Verify `from httpware import ResponseDecoder, PydanticDecoder` resolves at the REPL + +- [x] **Task 5 — Unit tests for type categories** (AC7, AC10) + - [x] Create `tests/test_decoders_pydantic.py` + - [x] Define fixtures: a `pydantic.BaseModel` subclass `User`, a stdlib `@dataclasses.dataclass` `UserDC` + - [x] Parametrized success test over five (type, bytes, assertion) tuples — AC7 table + - [x] Negative test: `pydantic.ValidationError` surfaces unchanged on malformed input (AC10) + +- [x] **Task 6 — Cache invariance tests** (AC8, AC11) + - [x] In the same test file, add a synchronous test that monkeypatches the `TypeAdapter` constructor, calls `decode(content, User)` 1000 times, and asserts exactly one construction + - [x] Add an async test that schedules 50 concurrent `decode` coroutines after `_get_adapter.cache_clear()`, asserts exactly one construction + - [x] Add a third test asserting two distinct model types in the same loop trigger exactly two constructions + +- [x] **Task 7 — Benchmark** (AC9) + - [x] Create `tests/test_decoders_pydantic_bench.py` + - [x] Fixture: deterministic 5KB JSON payload (list of `User` dicts; pad to ~5120 bytes) plus `User` BaseModel definition + - [x] Two `pytest-benchmark` cases: single-pass (`PydanticDecoder().decode(content, list[User])`) vs two-pass (`TypeAdapter(list[User]).validate_python(json.loads(content))`) + - [x] Assert `single_pass.stats.mean * 2 <= two_pass.stats.mean` (or equivalent ratio assertion) + - [x] Mark benchmark to run under `pytest --benchmark-only` invocation as well as regular `pytest` + +- [x] **Task 8 — Changelog + status flip** (AC14) + - [x] Append a bullet under `Unreleased` → `Added` in `CHANGELOG.md` + - [x] Update front-matter `status:` and trailing `## Status` in this story file to `review` + - [x] Fill in `Dev Agent Record` → `Implementation Plan`, `Debug Log`, `Completion Notes`, and `File List` + - [x] Append a new row to `## Change Log` + +- [x] **Task 9 — Final verification** + - [x] `just lint` clean + - [x] `just test` passes; full suite count grows by 5 unit tests (AC7) + 3 cache tests (AC8/AC11) + 1 negative test (AC10) + 2 benchmark cases (AC9) ≈ 11 new tests (one parametrized case still counts as several test IDs) + - [x] 100% line + branch coverage on `src/httpware/decoders/__init__.py` and `src/httpware/decoders/pydantic.py` + - [x] Full suite coverage remains ≥90% on the threshold-tracked modules + - [x] `from httpware import ResponseDecoder, PydanticDecoder` succeeds; both appear in `httpware.__all__` + - [x] `grep -rE 'import httpx2|from httpx2' src/httpware/` still returns exactly one match (`transports/httpx2.py` — unchanged from Story 1.4) + +## Dev Notes + +### Architecture references (authoritative — read these before coding) + +- `docs/architecture.md` § **Decision 8 — ResponseDecoder protocol** (lines 270–283). Authoritative shape; ship verbatim. +- `docs/architecture.md` § **Seam 3 — AsyncClient ↔ ResponseDecoder** (lines 703–708). Documents the v1.x public-contract status of the protocol. +- `docs/architecture.md` § **Structure Patterns** (lines 426–429). `decoders/__init__.py` holds the protocol; `decoders/pydantic.py` holds the cached TypeAdapter adapter; `decoders/msgspec.py` is extras-gated (Story 1.6). +- `docs/architecture.md` § **Type-Hint Style** (lines 463–472). No `from __future__ import annotations`; PEP 604 unions; `@runtime_checkable` only where isinstance gating is real. +- `docs/architecture.md` § **Async Naming** (lines 474–478). `decode` is **sync** `def` — CPU-bound; no `a` prefix. +- `docs/architecture.md` § **Optional-Extra Import Pattern** (lines 509–530). **Does NOT apply** to `decoders/pydantic.py` (pydantic is a base dep). Will apply to `decoders/msgspec.py` in Story 1.6. +- `docs/architecture.md` § **Public API Export Discipline** (lines 531–536). `__all__` is the single source of truth; CI snapshot test asserts the set. +- `docs/architecture.md` § **Cross-cutting concerns → NFR2** (line 43): "Module-level cache keyed by `response_model` — explicit memoization layer". +- `docs/prd.md` **FR31–FR35** (lines 622–626). The functional contract for the decoder family. +- `docs/prd.md` **NFR1** (line 653; ≤15% overhead), **NFR2** (line 654; zero `TypeAdapter` constructions per request after warm-up), **NFR3** (line 655; single parse pass), **NFR17** (line 678; ty + py.typed), **NFR20** (line 684; pydantic v2 compatibility), **NFR23** (line 690; coverage exclusion for adapters). +- `docs/epics.md` **Story 1.5** (lines 338–352) — authoritative AC source; this file expands it. +- `CLAUDE.md` § Code conventions — kwargs-only exception construction (unchanged here), `# ty: ignore[…]` only, no `print()`, absolute imports inside `src/httpware/`. +- `docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md` — the protocol+adapter shape pattern to mirror (especially: structural-only conformance, `@runtime_checkable`, module-level `__all__`, RUF022 expansion of public `__all__`). + +### Key design points + +**`ResponseDecoder` is a structural Protocol, not an ABC.** Same rationale as `Transport` (Story 1.4): `@runtime_checkable` enables `isinstance` gating at `AsyncClient(decoder=...)` (Story 1.7), and structural typing means `PydanticDecoder` does not need to inherit. **Do not** make `PydanticDecoder` inherit from `ResponseDecoder` — inheritance would be redundant with the structural check and creates needless coupling. + +**Per-method `TypeVar`, not class-level generic.** The protocol's `T` is bound per `decode` call, not per decoder instance. One decoder handles many model types. Express this by placing `T` at module scope (not in `Protocol[T]`): + +```python +T = TypeVar("T") + +@runtime_checkable +class ResponseDecoder(Protocol): + def decode(self, content: bytes, model: type[T]) -> T: ... +``` + +This matches the architecture's snippet at lines 275–277 verbatim. Class-level `Protocol[T]` would force `decoder: ResponseDecoder[User]`, breaking the "single decoder, many models" use case (and the AsyncClient method overloads in Story 1.7). + +**Module-level cache, not instance-level.** `_get_adapter` is a free function (not a `PydanticDecoder` method) decorated with `@functools.lru_cache(maxsize=None)`. Rationale: (1) pydantic's official guidance is "instantiate `TypeAdapter` once and reuse"; (2) the cache key is `model`, which is identical regardless of which `PydanticDecoder` instance asked; (3) module-level cache means multiple `PydanticDecoder()` instances (e.g., from `client.with_options(decoder=...)` later) share the cache, which is what users want. Trade-off: the cache lives for the process lifetime — but the bound is the consumer's schema universe, not request volume, so `maxsize=None` is safe (matches the architecture line 280). + +**`functools.lru_cache` thread-safety is sufficient.** CPython's `functools.lru_cache` is thread-safe for the operations it performs (the cache dict is protected by GIL atomicity for the lookup/insert path). The narrow race window — two threads call `_get_adapter(SameModel)` simultaneously when the cache is empty, both construct a `TypeAdapter`, one wins the cache slot, the other's instance is GC'd — is acceptable per pydantic's reuse semantics (`TypeAdapter` construction is idempotent; two instances for the same model decode identically). The AC8/AC11 tests verify the post-warmup invariant, not absolute serialization of cache writes. **Do not** add an `asyncio.Lock` or `threading.Lock` around `_get_adapter` — it would only protect a degenerate race that doesn't affect correctness, at the cost of contention on every cached lookup. + +**Single parse pass is the entire performance story.** `pydantic.TypeAdapter(...).validate_json(bytes)` parses JSON and validates against the schema in one Rust-side traversal. The two-pass alternative — `json.loads(bytes)` then `validate_python(parsed)` — parses JSON to a Python dict (allocations everywhere) and then re-walks it inside pydantic-core. NFR3 codifies this; AC9's benchmark proves the 2× minimum. Architecture line 279: "Operates on raw `bytes` (NFR3 — single parse pass)". + +**`pydantic` is a base dependency, not extras.** `pyproject.toml` line 32: `"pydantic>=2.0,<3.0"`. The Optional-Extra Import Pattern (architecture lines 509–530) is for `msgspec`, `otel`, `niquests` — not pydantic. Top-of-file `from pydantic import TypeAdapter` is correct; no `try/except ImportError` shim. + +**The `decoders/pydantic.py` module name shadows nothing.** Python 3 absolute imports always resolve to top-level `sys.path` packages first; `from pydantic import ...` from inside `httpware/decoders/pydantic.py` resolves to the third-party `pydantic`, not the current module. Relative-import syntax (`from . import pydantic`) would be needed to reach the local module, which we never do. This is a deliberate choice in the architecture (line 428: `pydantic.py # PydanticDecoder + TypeAdapter cache`) — name the module by what it adapts, not what it adapts *to*. + +**`ValidationError` surfaces unchanged.** The architecture and PRD do not specify wrapping `pydantic.ValidationError` in an httpware exception. FR36 ("framework raises `httpware`-owned exceptions only") refers to the transport seam — translating httpx2 exceptions at `Httpx2Transport`. The decoder's failure mode is the consumer's contract violation with their own `response_model`; wrapping `ValidationError` would hide pydantic's structured error location data behind a thinner shim. Flag for reviewer (Open Questions). + +**`decode` is sync, not async.** Decoding is CPU-bound; making it async would invite event-loop blocking with no benefit. The Story 1.7 `AsyncClient.get(..., response_model=User)` path will simply call `decoder.decode(response.content, User)` synchronously after the awaitable transport call returns — the architecture confirms this at line 705 ("the client invokes `decoder.decode(response.content, response_model)`"). + +**Single-decoder-instance is the default in Story 1.7.** `AsyncClient.__init__` will use `decoder=PydanticDecoder()` if none is supplied (Story 1.7 AC, epic line 382). Therefore `PydanticDecoder` must be cheaply instantiable (no expensive `__init__` work). The lru-cache lives at module scope, so default construction is `O(1)`. + +### Public API Surface (canonical `__all__` after this story) + +After Story 1.5, `httpware/__init__.py` must re-export exactly these **26** symbols in RUF022 order (ALL-CAPS first, then mixed-case alphabetically): + +```python +__all__ = [ + "STATUS_TO_EXCEPTION", + "BadRequestError", + "ClientConfig", + "ClientError", + "ClientStatusError", + "ConflictError", + "ForbiddenError", + "Httpx2Transport", + "InternalServerError", + "Limits", + "NotFoundError", + "PydanticDecoder", + "RateLimitedError", + "Request", + "Response", + "ResponseDecoder", + "ServerStatusError", + "ServiceUnavailableError", + "StatusError", + "StreamResponse", + "Timeout", + "TimeoutError", + "Transport", + "TransportError", + "UnauthorizedError", + "UnprocessableEntityError", +] +``` + +That's 26 entries: 24 from Story 1.4 + 2 new (`PydanticDecoder`, `ResponseDecoder`). The CI gate is `ruff check --select RUF022`. (`PydanticDecoder` sorts between `NotFoundError` and `RateLimitedError`; `ResponseDecoder` between `Response` and `ServerStatusError`. Trust ruff.) + +### What lives where after this story + +| File | New / modified | Contents | +|---|---|---| +| `src/httpware/decoders/__init__.py` | **new** | `@runtime_checkable` `ResponseDecoder(Protocol)` with `decode`. Module-level `T = TypeVar("T")`. | +| `src/httpware/decoders/pydantic.py` | **new** | `PydanticDecoder`: no-arg `__init__`, `decode(content, model)` calling `_get_adapter(model).validate_json(content)`. Module-level `_get_adapter` with `@functools.lru_cache(maxsize=None)`. | +| `src/httpware/__init__.py` | **modify** | Add 2 new re-exports; expand `__all__` from 24 to 26. | +| `tests/test_decoders_pydantic.py` | **new** | AC7 (5 type categories), AC8 + AC11 (cache invariance, sync + async + multi-model), AC10 (`ValidationError` surface). | +| `tests/test_decoders_pydantic_bench.py` | **new** | AC9 (≥2× single-pass vs two-pass on a 5KB payload). | +| `CHANGELOG.md` | **modify** | One new bullet under `Unreleased` → `Added`. | + +### Read-before-edit (per architect's guidance) + +Files this story modifies (read current state before editing): + +- `src/httpware/__init__.py` — currently 54 lines (Story 1.4 close-out): module docstring, six `from httpware. import (...)` blocks (config, errors, request, response, transports, transports.httpx2), one `__all__` with 24 entries. Add two new imports (`from httpware.decoders import ResponseDecoder`, `from httpware.decoders.pydantic import PydanticDecoder`), and extend `__all__` to 26 entries. `ruff check --fix` will re-sort imports and `__all__`. +- `CHANGELOG.md` — `Unreleased` → `Added` has bullets for Stories 1.1, 1.2, 1.3, 1.4. Append at the end of that section; do not reformat existing entries. + +Files this story creates (no prior state to preserve): `src/httpware/decoders/__init__.py`, `src/httpware/decoders/pydantic.py`, `tests/test_decoders_pydantic.py`, `tests/test_decoders_pydantic_bench.py`. + +### Carryover from Story 1.4 + +- The `Response` type from Story 1.2 has a `.content: bytes` field — the decoder consumes this field directly (the AsyncClient call site in Story 1.7 will pass `response.content` as the first argument to `decode`). +- `Httpx2Transport.__call__` (Story 1.4) already returns `Response(content=resp.content, ...)` where `resp.content` is `bytes`; no transport-side changes are needed. +- The `Transport` protocol pattern (Story 1.4) — `@runtime_checkable` `Protocol` with structural conformance, no inheritance for the default adapter — is the template this story mirrors. Differences: the decoder is sync; the protocol is non-generic at class level but generic at method level. +- The `__all__` expansion convention (Story 1.4: 21 → 24) — append, run `ruff check --fix`, trust RUF022 — is the same pattern here (24 → 26). +- The "lazy `httpx2.AsyncClient`" pattern from Story 1.4 (event-loop binding) **does not apply** to this story: `TypeAdapter` construction is loop-independent, and the cache is lazy by virtue of `lru_cache`'s on-demand population. +- `pytest-asyncio` is in `auto` mode — async tests don't need `@pytest.mark.asyncio` (architecture line 548). +- `from __future__ import annotations` is forbidden anywhere (architecture line 465, CLAUDE.md line 27). +- `# ty: ignore[]` is the only allowed type-suppression form (CLAUDE.md line 11). + +### Anti-patterns to reject (will fail review or CI) + +- ❌ `from __future__ import annotations` anywhere. +- ❌ `Protocol[T]` (class-level generic) instead of per-method `T` — would break "one decoder, many models" downstream in Story 1.7's overload typing. +- ❌ Making `PydanticDecoder` inherit from `ResponseDecoder`. Structural subtyping is the design (architecture line 282 applied to decoders). +- ❌ Adding `try/except ImportError` around `from pydantic import TypeAdapter`. Pydantic is a base dep — the Optional-Extra Import Pattern doesn't apply here. (Story 1.6 will apply it to msgspec.) +- ❌ Instance-level `lru_cache` (e.g., `@functools.lru_cache` on `self.decode`). Methods bound on `self` interact badly with `lru_cache` (the cache becomes per-call-bound-method, not per-class) and prevent multi-decoder cache sharing. Use a module-level free function. +- ❌ `lru_cache(maxsize=128)` or any bounded size. The model space is bounded by the consumer's schemas; eviction would force re-construction at the worst possible time (warm production). Architecture line 280 explicitly says `maxsize=None`. +- ❌ Two-pass parse: `validate_python(json.loads(content))`. Violates NFR3 explicitly; AC9's 2× benchmark would fail. +- ❌ Catching `pydantic.ValidationError` inside `decode` and re-raising as a different type. AC10 forbids it; FR36 does not require it for the decoder seam (see Open Questions). +- ❌ `decode` as `async def`. Decoding is CPU-bound; async would invite event-loop blocking and would not match the architecture's signature (line 276). +- ❌ Calling `pydantic.TypeAdapter(model).validate_json(content)` directly inline (skipping `_get_adapter`). Bypasses the cache; violates NFR2; AC8 would fail. +- ❌ Importing the local module via relative syntax (`from .pydantic import PydanticDecoder` from outside `decoders/`). The architecture's import rule (line 559) permits relative imports only within the same subpackage; `httpware/__init__.py` uses absolute `from httpware.decoders.pydantic import PydanticDecoder`. +- ❌ Putting `_get_adapter` in `__all__`. It's private (underscore prefix). +- ❌ Wrapping `decode` in a `try/except` to attach extra context. The decoder is one of the few "raise-through" surfaces in the library. +- ❌ Adding a `pydantic.BaseModel` subclass to `httpware/_internal/` or anywhere in the library code. The decoder is the only place pydantic is referenced; consumers bring their own models. +- ❌ `Optional[X]` / `Union[X, Y]` — use `X | None` / `X | Y` (PEP 604). +- ❌ `typing.List`, `typing.Dict` — use `list`, `dict` (PEP 585). +- ❌ Adding extra `__init__.py` files to `tests/` subdirectories. + +### Testing standards summary + +- `pytest-asyncio` auto mode — async test functions don't need `@pytest.mark.asyncio` (architecture line 548). +- `pytest-benchmark` for AC9 (already in dev deps; `pyproject.toml` line 62). +- Parametrize the AC7 type-category test for density. +- For AC8/AC11 cache tests: use `unittest.mock.patch` on `httpware.decoders.pydantic.TypeAdapter` (patching at the call site, per pytest mock conventions). Clear the lru-cache between tests with `_get_adapter.cache_clear()` (importable via `from httpware.decoders.pydantic import _get_adapter`). +- Coverage target: **100% line + branch** on both new decoder modules. NFR23 excludes decoders from the project's ≥90% gate, but the modules are tiny (~25 LOC combined) and full coverage is cheap. +- No Hypothesis tests in this story (no concurrency-sensitive primitives; AC11 is a single async test, not a property test). +- No real-network tests in this story; all tests are pure-function or pure-coroutine (no transport involved). +- No `respx` (architecture line 553) — not relevant here since the decoder is transport-independent. +- The benchmark file (`tests/test_decoders_pydantic_bench.py`) is part of the regular `pytest` suite; pytest-benchmark hooks pick it up automatically. The `--benchmark-only` invocation runs only benchmark tests; the default `pytest` invocation runs benchmarks alongside unit tests (with the benchmark assertion verifying the ratio). + +### Definition of Done + +- All 14 ACs verified (each AC maps to at least one test in `tests/test_decoders_pydantic.py` / `tests/test_decoders_pydantic_bench.py`, or to a check in `just lint`, or to a `__all__`/import assertion). +- All Task/Subtask checkboxes are `[x]`. +- `ruff format`, `ruff check`, `ty check`, `pytest` all pass locally with zero diagnostics. +- Coverage 100% (line + branch) on `src/httpware/decoders/__init__.py` AND `src/httpware/decoders/pydantic.py`. Threshold-tracked coverage on the prior modules holds. +- `from httpware import ResponseDecoder, PydanticDecoder` succeeds; `__all__` contains both; RUF022 is clean; total `__all__` length is 26. +- `grep -rE 'import httpx2|from httpx2' src/httpware/` still returns exactly one path: `src/httpware/transports/httpx2.py` — unchanged from Story 1.4. +- File List below is updated to reflect every changed and new file. +- `CHANGELOG.md` has a new dated bullet under `Unreleased` → `Added`. +- Front-matter `status:` and trailing `## Status` are both set to `review`. + +### Open questions / things to flag for reviewer + +- **`pydantic.ValidationError` policy (FR36 tension).** AC10 surfaces `pydantic.ValidationError` unchanged. FR36 says "framework raises `httpware`-owned exceptions only". The tension: FR36 is about *transport* exception leakage (httpx2 types); the decoder seam is on the *consumer's* side of the model contract. The architecture (Decision 8) does not specify wrapping. Defaults: do not wrap. Alternatives: (a) define a thin `DecodingError(ClientError)` wrapper in `errors.py` and translate at the seam, preserving `__cause__`; (b) leave as pydantic. Flag. +- **`+json` content types and friends.** The decoder accepts `bytes` regardless of the response's content-type. `application/json`, `application/vnd.api+json`, `application/problem+json` all parse identically because the bytes are JSON. The decoder does not inspect content-type — that's the caller's responsibility (or a future middleware's). Confirm reviewer is OK with the "decoder trusts the bytes" contract. +- **`maxsize=None` cache eviction policy.** Unbounded by design (architecture line 280, prd NFR2). The risk surface: a consumer that builds many short-lived ad-hoc model types in a loop (e.g., dynamic pydantic class generation) could leak memory. Real-world consumers don't do this; flag if reviewer wants a `maxsize=1024` safety cap. +- **Multiple `PydanticDecoder` instances share the module cache.** A consumer who builds two `AsyncClient` instances with different `PydanticDecoder()` instances will see the *same* `TypeAdapter` per model across both clients. This is correct (TypeAdapter has no per-client state), but worth confirming — flag if reviewer prefers per-instance cache for isolation (would require attaching the cache to `self` and breaking the module-level free function — see "Anti-patterns" for why we reject that). +- **Concurrent first-call race window.** AC11 documents that two concurrent first-calls may both construct a `TypeAdapter` before one wins the cache slot, and that this is acceptable. Story 1.4 used an explicit `asyncio.Lock` for the same shape of race (lazy `httpx2.AsyncClient`). The difference: there, the client was a heavyweight resource with side effects (event-loop binding); here, `TypeAdapter` construction is pure and idempotent, so a loser instance is just a GC microcost. Flag if reviewer wants symmetry with Story 1.4's pattern. +- **Benchmark stability under CI.** AC9 asserts ≥2× speedup. Real-world ratios on `validate_json(bytes)` vs `validate_python(json.loads(bytes))` are typically 3–5×, so 2× is conservative. But CI runners are noisy. Possible mitigations: `pytest.mark.benchmark(group="decoder")` to allow `--benchmark-compare`; `pytest-benchmark` already uses statistical means. If CI flakes, the fallback is to widen to ≥1.5× and re-flag the NFR3 phrasing. +- **`Protocol` + per-method `TypeVar` for `ty`.** AC12 assumes `ty` accepts this shape (same shape as `Transport`'s signature in Story 1.4 — which did pass `ty`). If `ty` regresses or the decoder shape triggers a new diagnostic, the workaround is `class ResponseDecoder(Protocol):` with `decode` annotated using a `@typing.overload`-free `TypeVar` re-binding. Don't introduce `ty: ignore` unless you've exhausted other shapes. + +## Change Log + +| Date | Change | Notes | +|---|---|---| +| 2026-05-14 | Story created | Drafted from `docs/epics.md` Story 1.5 + `docs/architecture.md` Decision 8 + Story 1.4's adapter pattern; expanded the epic's 5-clause AC into AC1–AC14 for traceability; verified pydantic v2 `TypeAdapter` API surface and reuse guidance via context7; confirmed `pydantic` is a base dep (not extras-gated, unlike Story 1.6's msgspec); noted Python 3.11 floor implications for protocol generic syntax; flagged FR36 tension over `ValidationError` wrapping, `+json` content-type policy, cache eviction, and benchmark CI stability for reviewer. | +| 2026-05-14 | Story implemented | Shipped `ResponseDecoder` protocol + `PydanticDecoder` adapter + module-level `_get_adapter` cache; added 11 unit/cache tests + 3 benchmark tests; 100% line+branch coverage on both new decoder modules; `__all__` 24 → 26 entries; `just lint` + `just test` clean. AC9 benchmark threshold relaxed to ≥1.5× per Open Questions item 5 (≥2× not portable on this hardware/pydantic-core version); reviewer to confirm the documented fallback. | +| 2026-05-14 | Code review applied | Parallel-layer review (Blind Hunter + Edge Case Hunter + Acceptance Auditor): 5 decision-needed → resolved, 2 patches applied, 4 deferred (in `docs/deferred-work.md`), 15 dismissed. Amendments: AC3 `maxsize=None` → `maxsize=1024` (Decision 1, Open Q 3); AC4 added `try/except TypeError` fallback for unhashable `model` (Decision 5); AC9 ≥2× → ≥1.5× and gated behind `pytest -m perf` (Decisions 2 + 3, Open Q 5); added `ThreadPoolExecutor`-based concurrent cache-invariance test (Decision 4); added autouse `_clear_adapter_cache` fixture to unit tests; `_warm_cache` bench fixture moved to `scope="module"`. | + +## Dev Agent Record + +### Implementation Plan + +1. Create `decoders/__init__.py` with `ResponseDecoder(Protocol)` carrying a single per-call-generic `decode` method, gated by `@runtime_checkable`. `T = TypeVar("T")` at module scope per AC1/AC12. +2. Create `decoders/pydantic.py` with module-level `@functools.lru_cache(maxsize=None)` factory `_get_adapter(model) -> TypeAdapter[T]` and a stateless `PydanticDecoder` whose `decode` is `return _get_adapter(model).validate_json(content)`. No inheritance from the protocol. +3. Wire two new symbols into `src/httpware/__init__.py`; let `ruff check --fix` resolve RUF022 ordering (24 → 26 entries). +4. Tests: parametrize-free unit tests covering AC7's 5 type categories, AC10 `ValidationError` surfacing, AC8 single-model and two-model cache invariance via `unittest.mock.patch(..., wraps=pydantic.TypeAdapter)`, AC11 50-coroutine concurrent first-call test against a freshly-cleared cache. +5. Benchmark file: spec-aligned `list[User]` shape; two `pytest-benchmark` cases (single-pass + two-pass) plus a deterministic ratio assertion using `time.perf_counter_ns` with GC disabled. +6. CHANGELOG bullet, story bookkeeping. + +### Debug Log + +- Initial `_get_adapter` decoration: ruff rewrote `@functools.lru_cache(maxsize=None)` → `@functools.cache` (UP033). AC3 specifies the literal form, so the rewrite was reverted with `# noqa: UP033` to keep the AC-verbatim signature. +- AC9 benchmark threshold (≥2×) was not achievable on this dev machine (Darwin 24.3.0 / Apple Silicon / pydantic 2.13.4 + pydantic_core 2.46.4): the measured `validate_json(bytes)` vs `validate_python(json.loads(bytes))` ratio for `list[User]` shapes lands at 1.18×–1.65× depending on payload composition. The Open Questions section of this story (item 5) preemptively documented a ≥1.5× fallback for this exact CI/portability scenario; the benchmark file uses `SPEEDUP_FLOOR = 1.5` with a comment citing the open question. The decoder still uses `validate_json` (single parse pass), so the structural NFR3 invariant is satisfied — only the numerical proxy was relaxed. Flagged in Completion Notes for reviewer. +- Pyright IDE diagnostics about unresolved `httpware.decoders.pydantic` imports were stale (new module not yet indexed); `ty check` is the project's actual gate and reported zero diagnostics. No code changes were warranted. + +### Completion Notes + +- All 14 ACs satisfied with one documented deviation: AC9 uses `SPEEDUP_FLOOR = 1.5` (per the story's own Open Questions item 5) instead of the literal `≥2×`. Local median ratio across runs: ~1.63×. Reviewer to confirm whether to (a) keep the documented fallback, (b) re-tune the benchmark payload (e.g., a `dict[str, list[int]]` shape consistently exceeds 2×) at the cost of drifting from the AC's "User-like dicts" wording, or (c) tighten when CI hardware/pydantic-core versions move. +- Decoder modules reach 100% line + branch coverage; full suite jumps from 142 → 156 tests (+14). All 156 pass, full-suite line+branch coverage = 99% (the remaining 1% is pre-existing partial branches in `transports/httpx2.py` and `response.py`). +- `from httpware import ResponseDecoder, PydanticDecoder` resolves; `httpware.__all__` has 26 entries and is RUF022-sorted. +- `grep -rE 'import httpx2|from httpx2' src/httpware/` still returns exactly one path (`src/httpware/transports/httpx2.py`) — Story 1.4 invariant preserved. +- The cache-invariance tests patch `httpware.decoders.pydantic.TypeAdapter` with `wraps=pydantic.TypeAdapter` (real construction still happens, so the cache holds a working adapter), `_get_adapter.cache_clear()` runs in test setup to neutralize prior fills, and `spy.call_count` is asserted exactly once for the same-model 1000-call loop and exactly twice for the two-distinct-model loop. The async concurrent-first-call test schedules 50 coroutines through `asyncio.gather`; since `decode` is sync there is no true preemption point, but the test still verifies the post-warmup invariant the story requires. + +## File List + +**New** +- `src/httpware/decoders/__init__.py` +- `src/httpware/decoders/pydantic.py` +- `tests/test_decoders_pydantic.py` +- `tests/test_decoders_pydantic_bench.py` + +**Modified** +- `src/httpware/__init__.py` — added two re-exports; `__all__` 24 → 26 entries (RUF022-sorted). +- `CHANGELOG.md` — one new bullet under `Unreleased` → `Added`. +- `docs/stories/sprint-status.yaml` — `1-5-responsedecoder-protocol-and-pydantic-adapter`: `ready-for-dev` → `in-progress` → `review`. +- `docs/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md` — Tasks/Subtasks checked, status flipped, Dev Agent Record and File List populated, Change Log row appended. + +## Review Findings + +Code review performed 2026-05-14 (parallel: Blind Hunter + Edge Case Hunter + Acceptance Auditor). 26 raw findings → 5 decision-needed, 2 patch, 4 deferred, 15 dismissed. + +### Decision-needed (resolved 2026-05-14) + +- [x] [Review][Decision] **Unbounded `lru_cache` cache leak with dynamic `pydantic.create_model` types** — Resolved: cap at `maxsize=1024`; AC3 amended. (Decision 1 / Open Q 3) +- [x] [Review][Decision] **AC9 benchmark threshold relaxed from ≥2× to ≥1.5×** — Resolved: keep `SPEEDUP_FLOOR = 1.5`; AC9 text amended to ≥1.5× citing Open Q 5. (Decision 2) +- [x] [Review][Decision] **Benchmark timing test runs in default `pytest` invocation** — Resolved: timing test gated behind `@pytest.mark.perf`; `pyproject.toml` excludes via `addopts = "... -m 'not perf'"`; run with `pytest -m perf`. (Decision 3) +- [x] [Review][Decision] **AC11 async test does not exercise real concurrency** — Resolved: kept async test (post-warmup invariant), added `test_cache_invariance_concurrent_first_calls_threadpool` with `ThreadPoolExecutor` to exercise the real GIL-level race. (Decision 4) +- [x] [Review][Decision] **Decoder contract for unhashable `model` arguments** — Resolved: added `try/except TypeError` fallback in `decode`; AC4 amended; new test `test_unhashable_model_falls_back_to_uncached_adapter` pins the contract. (Decision 5) + +### Patch (applied 2026-05-14) + +- [x] [Review][Patch] Added module-level autouse `_clear_adapter_cache` fixture to `tests/test_decoders_pydantic.py`. +- [x] [Review][Patch] Changed `_warm_cache` in `tests/test_decoders_pydantic_bench.py` to `scope="module"`. + +### Deferred + +- [x] [Review][Defer] Empty/malformed payload tests (`b""`, `b"null"`, `b"{}"`, invalid UTF-8) — current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change the error type undetected. Defer to a dedicated decoder-contract test pass. [`tests/test_decoders_pydantic.py`] +- [x] [Review][Defer] Move `# noqa: PLR2004` to `tool.ruff.lint.per-file-ignores` for `tests/*` — repeated four times in this file; the per-file-ignores config is the idiomatic fix and would also clean up prior test modules. Defer to a project-wide lint-config tidy. [`tests/test_decoders_pydantic.py:48,57,68,82`] +- [x] [Review][Defer] CHANGELOG bullet leaks implementation detail (`_get_adapter`, "zero adapter-construction cost") into a user-facing log — style only; AC14 sets no wording constraint. Defer to a CHANGELOG tone pass before v1 release. [`CHANGELOG.md:19`] +- [x] [Review][Defer] `@runtime_checkable` `isinstance` cost when Story 1.7's `AsyncClient(decoder=...)` gate is added (~µs per check; matters only if the gate runs per-request rather than per-construction). Defer to Story 1.7 design. [N/A — future story concern] + +### Dismissed + +15 findings dismissed as noise or spec-permitted: `# noqa: UP033` is correct (verified via `ruff rule UP033`); `@runtime_checkable` attribute-only check is the spec's chosen design (mirrors Story 1.4's Transport); `type(result) is X` is AC7-mandated; AC6's alphabetic `__all__` is spec; `_get_adapter` private import in tests is spec-authorized; `pydantic.py` module-name shadowing was pre-verified; `PydanticDecoder` instance ceremony is intentional (module-level cache is the design); CHANGELOG content choice is spec-permitted; etc. diff --git a/docs/stories/sprint-status.yaml b/docs/stories/sprint-status.yaml new file mode 100644 index 0000000..4be355d --- /dev/null +++ b/docs/stories/sprint-status.yaml @@ -0,0 +1,93 @@ +# generated: 2026-05-14 +# last_updated: 2026-05-14 # story 1-5 → done (code review applied) +# project: httpware +# project_key: NOKEY +# tracking_system: file-system +# story_location: docs/stories + +# STATUS DEFINITIONS: +# ================== +# Epic Status: +# - backlog: Epic not yet started +# - in-progress: Epic actively being worked on +# - done: All stories in epic completed +# +# Epic Status Transitions: +# - backlog → in-progress: Automatically when first story is created (via create-story) +# - in-progress → done: Manually when all stories reach 'done' status +# +# Story Status: +# - backlog: Story only exists in epic file +# - ready-for-dev: Story file created in stories folder +# - in-progress: Developer actively working on implementation +# - review: Ready for code review (via Dev's code-review workflow) +# - done: Story completed +# +# Retrospective Status: +# - optional: Can be completed but not required +# - done: Retrospective has been completed +# +# WORKFLOW NOTES: +# =============== +# - Epic transitions to 'in-progress' automatically when first story is created +# - Stories can be worked in parallel if team capacity allows +# - Developer typically creates next story after previous one is 'done' to incorporate learnings +# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) + +generated: 2026-05-14 +last_updated: 2026-05-14 # story 1-5 → done (code review applied) +project: httpware +project_key: NOKEY +tracking_system: file-system +story_location: docs/stories + +development_status: + epic-1: in-progress + 1-1-project-scaffold-and-tooling: done + 1-2-core-data-types: done + 1-3-exception-hierarchy-with-plain-fields: done + 1-4-transport-protocol-and-httpx2transport-adapter: done + 1-5-responsedecoder-protocol-and-pydantic-adapter: done + 1-6-msgspec-decoder-via-extras: backlog + 1-7-asyncclient-with-http-methods-response-model-with-options-lifecycle: backlog + 1-8-recordedtransport-for-testing: backlog + epic-1-retrospective: optional + + epic-2: backlog + 2-1-middleware-protocol-next-type-and-chain-composition: backlog + 2-2-phase-shortcut-decorators: backlog + 2-3-request-immutability-helpers: backlog + 2-4-auth-coercion-as-middleware: backlog + 2-5-wire-middleware-into-asyncclient: backlog + epic-2-retrospective: optional + + epic-3: backlog + 3-1-timeout-middleware-per-attempt: backlog + 3-2-retry-middleware: backlog + 3-3-retrybudget-data-structure: backlog + 3-4-retrybudget-middleware-integration: backlog + 3-5-bulkhead-middleware: backlog + 3-6-document-the-extension-slot: backlog + epic-3-retrospective: optional + + epic-4: backlog + 4-1-streamresponse-type: backlog + 4-2-transport-stream-implementation-in-httpx2transport: backlog + 4-3-asyncclient-stream-context-manager: backlog + epic-4-retrospective: optional + + epic-5: backlog + 5-1-layer-1-observability-middleware-lifecycle-hooks: backlog + 5-2-wire-emission-into-resilience-middlewares: backlog + 5-3-redactor-class-and-integration: backlog + 5-4-opentelemetry-middleware: backlog + 5-5-logging-policy-enforcement: backlog + epic-5-retrospective: optional + + epic-6: backlog + 6-1-migration-guide-from-base-client: backlog + 6-2-documentation-site-mkdocs: backlog + 6-3-public-benchmark-suite: backlog + 6-4-ci-enforcement-gates: backlog + 6-5-release-flow-with-trusted-publishers-and-sigstore: backlog + epic-6-retrospective: optional diff --git a/pyproject.toml b/pyproject.toml index 162bb0d..70f05ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,10 +92,13 @@ isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] [tool.pytest.ini_options] -addopts = "--cov=src/httpware --cov-report term-missing" +addopts = "--cov=src/httpware --cov-report term-missing -m 'not perf'" asyncio_mode = "auto" pythonpath = ["src"] asyncio_default_fixture_loop_scope = "function" +markers = [ + "perf: assertive performance tests (skipped by default; run with `pytest -m perf`)", +] [tool.coverage] run.concurrency = ["thread"] diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index 8606226..bf94a04 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -1,6 +1,8 @@ """httpware — resilience-first async HTTP client framework for Python.""" from httpware.config import ClientConfig, Limits, Timeout +from httpware.decoders import ResponseDecoder +from httpware.decoders.pydantic import PydanticDecoder from httpware.errors import ( STATUS_TO_EXCEPTION, BadRequestError, @@ -37,9 +39,11 @@ "InternalServerError", "Limits", "NotFoundError", + "PydanticDecoder", "RateLimitedError", "Request", "Response", + "ResponseDecoder", "ServerStatusError", "ServiceUnavailableError", "StatusError", diff --git a/src/httpware/decoders/__init__.py b/src/httpware/decoders/__init__.py new file mode 100644 index 0000000..7b0e568 --- /dev/null +++ b/src/httpware/decoders/__init__.py @@ -0,0 +1,18 @@ +"""ResponseDecoder protocol — the AsyncClient ↔ ResponseDecoder seam (Seam 3).""" + +from typing import Protocol, TypeVar, runtime_checkable + + +T = TypeVar("T") + + +@runtime_checkable +class ResponseDecoder(Protocol): + """Structural protocol every response-body decoder satisfies.""" + + def decode(self, content: bytes, model: type[T]) -> T: + """Decode `content` (raw response bytes) into an instance of `model`.""" + ... + + +__all__ = ["ResponseDecoder"] diff --git a/src/httpware/decoders/pydantic.py b/src/httpware/decoders/pydantic.py new file mode 100644 index 0000000..5dc54be --- /dev/null +++ b/src/httpware/decoders/pydantic.py @@ -0,0 +1,29 @@ +"""PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder.""" + +import functools +from typing import TypeVar + +from pydantic import TypeAdapter + + +T = TypeVar("T") + + +@functools.lru_cache(maxsize=1024) +def _get_adapter(model: type[T]) -> TypeAdapter[T]: + return TypeAdapter(model) + + +class PydanticDecoder: + """Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`.""" + + def decode(self, content: bytes, model: type[T]) -> T: + """Validate `content` as JSON against `model` in a single parse pass.""" + try: + adapter = _get_adapter(model) + except TypeError: + adapter = TypeAdapter(model) + return adapter.validate_json(content) + + +__all__ = ["PydanticDecoder"] diff --git a/tests/test_decoders_pydantic.py b/tests/test_decoders_pydantic.py new file mode 100644 index 0000000..ba07a3d --- /dev/null +++ b/tests/test_decoders_pydantic.py @@ -0,0 +1,156 @@ +"""Tests for `httpware.decoders.pydantic.PydanticDecoder` (Story 1.5).""" + +import asyncio +import concurrent.futures +import dataclasses +from unittest.mock import patch + +import pydantic +import pytest + +from httpware import PydanticDecoder, ResponseDecoder +from httpware.decoders.pydantic import _get_adapter + + +class User(pydantic.BaseModel): + """Test pydantic model.""" + + id: int + name: str + + +@dataclasses.dataclass +class UserDC: + """Test stdlib dataclass model.""" + + id: int + name: str + + +@pytest.fixture(autouse=True) +def _clear_adapter_cache() -> None: + _get_adapter.cache_clear() + + +def test_pydantic_decoder_satisfies_response_decoder_protocol() -> None: + assert isinstance(PydanticDecoder(), ResponseDecoder) + + +def test_pydantic_decoder_does_not_inherit_response_decoder() -> None: + assert ResponseDecoder not in PydanticDecoder.__mro__ + + +def test_decodes_basemodel_subclass() -> None: + result = PydanticDecoder().decode(b'{"id": 1, "name": "Ada"}', User) + assert type(result) is User + assert result.id == 1 + assert result.name == "Ada" + + +def test_decodes_stdlib_dataclass() -> None: + result = PydanticDecoder().decode(b'{"id": 1, "name": "Ada"}', UserDC) + assert type(result) is UserDC + assert result.id == 1 + assert result.name == "Ada" + + +def test_decodes_list_of_models() -> None: + result = PydanticDecoder().decode( + b'[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}]', + list[User], + ) + assert type(result) is list + assert len(result) == 2 # noqa: PLR2004 + assert all(type(item) is User for item in result) + assert result[0].id == 1 + assert result[0].name == "Ada" + assert result[1].id == 2 # noqa: PLR2004 + assert result[1].name == "Bo" + + +def test_decodes_dict_of_models() -> None: + result = PydanticDecoder().decode(b'{"u1": {"id": 1, "name": "Ada"}}', dict[str, User]) + assert type(result) is dict + assert list(result.keys()) == ["u1"] + assert type(result["u1"]) is User + assert result["u1"].id == 1 + assert result["u1"].name == "Ada" + + +def test_decodes_primitive_int() -> None: + result = PydanticDecoder().decode(b"42", int) + assert type(result) is int + assert result == 42 # noqa: PLR2004 + + +def test_validation_error_surfaces_unchanged() -> None: + with pytest.raises(pydantic.ValidationError): + PydanticDecoder().decode(b'{"id": "not-a-number", "name": "Ada"}', User) + + +def test_cache_invariance_single_model() -> None: + _get_adapter.cache_clear() + with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy: + decoder = PydanticDecoder() + for _ in range(1000): + decoder.decode(b'{"id": 1, "name": "Ada"}', User) + assert spy.call_count == 1 + + +def test_cache_invariance_two_distinct_models() -> None: + _get_adapter.cache_clear() + with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy: + decoder = PydanticDecoder() + for _ in range(500): + decoder.decode(b'{"id": 1, "name": "Ada"}', User) + decoder.decode(b'{"id": 1, "name": "Ada"}', UserDC) + assert spy.call_count == 2 # noqa: PLR2004 — two distinct model types + + +async def test_cache_invariance_concurrent_first_calls() -> None: + _get_adapter.cache_clear() + with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy: + decoder = PydanticDecoder() + + async def one_decode() -> User: + return decoder.decode(b'{"id": 1, "name": "Ada"}', User) + + await asyncio.gather(*(one_decode() for _ in range(50))) + assert spy.call_count == 1 + + +def test_cache_invariance_concurrent_first_calls_threadpool() -> None: + _get_adapter.cache_clear() + n_workers = 20 + with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy: + decoder = PydanticDecoder() + + def one_decode(_: int) -> User: + return decoder.decode(b'{"id": 1, "name": "Ada"}', User) + + with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as pool: + results = list(pool.map(one_decode, range(50))) + + assert all(type(r) is User and r.id == 1 for r in results) + # functools.lru_cache serializes the cache slot but the user function runs + # outside the lock — concurrent first-callers may both build a TypeAdapter + # before one wins (idempotent; loser is GC'd). Bounded by worker count. + assert 1 <= spy.call_count <= n_workers + + +def test_unhashable_model_falls_back_to_uncached_adapter() -> None: + """Unhashable `model` falls back to a direct uncached `TypeAdapter`. + + When `_get_adapter` raises `TypeError` (e.g., `Annotated[int, unhashable_metadata]`), + `decode` bypasses the cache so `pydantic.ValidationError` surfaces cleanly instead + of leaking a `functools`-internal `TypeError` to the caller. + """ + with patch( + "httpware.decoders.pydantic._get_adapter", + side_effect=TypeError("unhashable type"), + ): + result = PydanticDecoder().decode(b"42", int) + assert result == 42 # noqa: PLR2004 + + with pytest.raises(pydantic.ValidationError): + PydanticDecoder().decode(b'"not-an-int"', int) diff --git a/tests/test_decoders_pydantic_bench.py b/tests/test_decoders_pydantic_bench.py new file mode 100644 index 0000000..49ff534 --- /dev/null +++ b/tests/test_decoders_pydantic_bench.py @@ -0,0 +1,110 @@ +"""Benchmark: single-pass `validate_json` is faster than two-pass (Story 1.5 AC9, NFR3).""" + +import gc +import json +import statistics +import time + +import pydantic +import pytest +from pytest_benchmark.fixture import BenchmarkFixture + +from httpware.decoders.pydantic import PydanticDecoder, _get_adapter + + +PAYLOAD_ITEMS = 30 +PAYLOAD_MIN_BYTES = 4500 +PAYLOAD_MAX_BYTES = 5500 +SPEEDUP_FLOOR = 1.5 # AC9 fallback per Open Questions item 5 (2x target not hardware-portable). + + +class _User(pydantic.BaseModel): + """Benchmark-only User shape: id, name, and a small attribute map.""" + + id: int + name: str + attributes: dict[str, int] + + +def _build_payload() -> bytes: + items = [ + { + "id": i, + "name": f"user-{i:03d}", + "attributes": {f"k{j:02d}": j * 7 for j in range(10)}, + } + for i in range(PAYLOAD_ITEMS) + ] + payload = json.dumps(items).encode("utf-8") + assert PAYLOAD_MIN_BYTES <= len(payload) <= PAYLOAD_MAX_BYTES, ( + f"payload size {len(payload)} outside acceptance window" + ) + return payload + + +@pytest.fixture +def payload() -> bytes: + return _build_payload() + + +@pytest.fixture(autouse=True, scope="module") +def _warm_cache() -> None: + _get_adapter.cache_clear() + PydanticDecoder().decode(_build_payload(), list[_User]) + + +@pytest.mark.benchmark(group="decoder", disable_gc=True) +def test_bench_single_pass_validate_json(benchmark: BenchmarkFixture, payload: bytes) -> None: + decoder = PydanticDecoder() + result = benchmark(decoder.decode, payload, list[_User]) + assert len(result) == PAYLOAD_ITEMS + + +@pytest.mark.benchmark(group="decoder", disable_gc=True) +def test_bench_two_pass_loads_then_validate(benchmark: BenchmarkFixture, payload: bytes) -> None: + adapter = pydantic.TypeAdapter(list[_User]) + + def two_pass() -> list[_User]: + return adapter.validate_python(json.loads(payload)) + + result = benchmark(two_pass) + assert len(result) == PAYLOAD_ITEMS + + +@pytest.mark.perf +def test_single_pass_is_measurably_faster_than_two_pass(payload: bytes) -> None: + decoder = PydanticDecoder() + adapter = pydantic.TypeAdapter(list[_User]) + + rounds = 60 + iterations = 30 + + gc.collect() + gc_was_enabled = gc.isenabled() + gc.disable() + try: + single_samples: list[float] = [] + for _ in range(rounds): + start = time.perf_counter_ns() + for _ in range(iterations): + decoder.decode(payload, list[_User]) + single_samples.append((time.perf_counter_ns() - start) / iterations) + + two_samples: list[float] = [] + for _ in range(rounds): + start = time.perf_counter_ns() + for _ in range(iterations): + adapter.validate_python(json.loads(payload)) + two_samples.append((time.perf_counter_ns() - start) / iterations) + finally: + if gc_was_enabled: + gc.enable() + + single_mean = statistics.median(single_samples) + two_mean = statistics.median(two_samples) + ratio = two_mean / single_mean + + assert ratio >= SPEEDUP_FLOOR, ( + f"NFR3 regression: single-pass {single_mean:.1f} ns/op, " + f"two-pass {two_mean:.1f} ns/op, ratio={ratio:.2f}x (need ≥ {SPEEDUP_FLOOR}x)" + )