From f72de1b8b1d7fd9600885f65d06f67416d8625db Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 9 Jun 2026 23:22:18 +0300 Subject: [PATCH 01/11] docs(spec): add multi-decoder routing design Reframes the decoder seam from one-decoder-per-client to a typed-dispatched `decoders=[...]` list. Removes the 0.3.0 eager-import fail-fast: `AsyncClient()` no longer raises on missing pydantic. Adds `MissingDecoderError` (fires before the HTTP call). Target release: 0.9.0. --- .../specs/2026-06-09-multi-decoder-design.md | 395 ++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 planning/specs/2026-06-09-multi-decoder-design.md diff --git a/planning/specs/2026-06-09-multi-decoder-design.md b/planning/specs/2026-06-09-multi-decoder-design.md new file mode 100644 index 0000000..66f30d9 --- /dev/null +++ b/planning/specs/2026-06-09-multi-decoder-design.md @@ -0,0 +1,395 @@ +# Spec: multi-decoder routing — `decoders=[...]` with type-dispatched claim policy + +**Date:** 2026-06-09 +**Topic slug:** `multi-decoder` +**Status:** drafted, awaiting user review +**Target release:** `0.9.0` (minor — breaking surface: `decoder=` → `decoders=`; behavioral: `AsyncClient()` no longer raises on missing pydantic) + +## Purpose + +Today, `AsyncClient()` / `Client()` constructed without `decoder=` calls `_default_pydantic_decoder()` (`src/httpware/client.py:40`), which raises `ImportError` at `__init__` time if the `pydantic` extra is missing. That fail-fast was the 0.3.0 design choice ([release_0_3_0_shipped] in memory) — at the time it modeled "pydantic is the de-facto default; surface the missing dep early." + +Two problems with that choice surfaced on coherence audit: + +1. **`pydantic` is documented as an *optional* extra in `pyproject.toml:35`, but `AsyncClient()` with no kwargs makes it mandatory.** Users who never call `.send(..., response_model=...)` — health checks, streaming, raw `response.json()`, HTML responses, webhooks — pay the dependency cost for a feature they don't use. The "optional" framing is misleading. +2. **The client carries a single `_decoder: ResponseDecoder` instance.** A user with mixed model types in one codebase — some endpoints returning `pydantic.BaseModel`, some returning `msgspec.Struct` — has no way to satisfy both. They must pick one decoder and either restrict their model choices or hand-write a dispatching `ResponseDecoder`. The "one decoder per client" invariant isn't justified by anything about HTTP; it's an accident of the original Seam B shape. + +This spec replaces the single-decoder slot with a **type-dispatched decoder list** and removes the eager-import fail-fast. After this lands: + +- `AsyncClient()` never raises on missing extras. Decoder availability is resolved from installed extras at `__init__`, falling back to `()` if neither is present. +- Users register a list: `AsyncClient(decoders=[PydanticDecoder(), MsgspecDecoder()])`. Each decoder declares which models it claims via a new `can_decode(model)` protocol method. The first decoder whose `can_decode` returns `True` for a given `response_model=` wins. +- A new `MissingDecoderError` (sibling of `DecodeError`, both under `ClientError`) fires *before* the HTTP request when `response_model=Foo` is set and no registered decoder claims `Foo`. Distinct from `DecodeError` (decoder ran, data was bad). + +The decoder kwarg is renamed `decoder=` → `decoders=`. Pre-1.0, clean cutover, no shim — consistent with the project's rewrite tradition ([user_prefers_clean_cutover_ordering] in memory). + +## Non-goals + +- **Per-call decoder override.** Considered and rejected (option C in brainstorm). The decoder list lives on the client and is frozen for its lifetime, mirroring how middleware is composed at `__init__`. Per-call override would split routing logic across two locations and confuse the seam. +- **Auto-detect at every `.send()` call.** The default decoder list is resolved once at `__init__` from `import_checker` flags. `is_pydantic_installed` / `is_msgspec_installed` are evaluated at import time of `_internal/import_checker.py` (`find_spec` calls at module top); the client snapshot reflects whatever was true then. Hot-patching a library post-client-construction is not supported. +- **Stdlib JSON fallback decoder.** No built-in `JsonDecoder` shipping in this spec. Users with `response_model=dict` / `response_model=list[...]` use whichever of pydantic / msgspec is registered; both libraries handle those shapes via their broad claim policy. If neither extra is installed, `MissingDecoderError` fires — install one or pass a custom decoder. +- **Changing how `DecodeError` works.** `DecodeError`'s contract (`response`, `model`, `original`, wraps via `raise ... from exc`) is unchanged. Only the new sibling `MissingDecoderError` is added. +- **Migration shim for `decoder=`.** Pre-1.0; the kwarg is renamed cleanly. Old code raises `TypeError: unexpected keyword argument 'decoder'` at `__init__`. Release notes flag it. + +## Architecture + +### Protocol shape — Seam B extended + +`ResponseDecoder` (`src/httpware/decoders/__init__.py:9`) gains one method: + +```python +@runtime_checkable +class ResponseDecoder(Protocol): + """Structural protocol every response-body decoder satisfies.""" + + def can_decode(self, model: type) -> bool: + """Return True iff this decoder claims responsibility for `model`. + + The client walks its `_decoders` tuple in order and picks the first + decoder whose `can_decode` returns True. Implementations should claim + every model type they can actually handle — broad is correct, because + list ordering encodes the caller's preference for shared shapes + (dataclass, primitive, parameterized generic, etc.). Native types of + another library (e.g. PydanticDecoder vs `msgspec.Struct`) MUST be + rejected. + """ + ... + + def decode(self, content: bytes, model: type[T]) -> T: ... +``` + +`can_decode` is required for all decoders — including user-written ones — because the dispatcher walks the protocol method. There is no implicit "catch-all" fallback. Custom decoders that want to claim everything return `True` unconditionally. + +### Claim policies — built-in decoders + +**`PydanticDecoder.can_decode`** (`src/httpware/decoders/pydantic.py`): + +```python +def can_decode(self, model: type) -> bool: + try: + _get_adapter(model) # cached TypeAdapter(model) + except Exception: + return False + return True +``` + +`_get_adapter` is the existing `@lru_cache`-decorated `TypeAdapter` constructor (`decoders/pydantic.py:28`). `TypeAdapter(model)` raises `pydantic.errors.PydanticSchemaGenerationError` (a `TypeError` subclass) for types pydantic can't build a schema from — most notably `msgspec.Struct`. For everything else (`BaseModel`, dataclass, `TypedDict`, primitive, `list[X]`, `dict[X, Y]`, `Foo | None`, `Annotated[...]`), `TypeAdapter` succeeds. + +The probe writes to the cache; the subsequent `decode` call reuses the same cached adapter. Probe and decode share a constant — no double cost. + +**`MsgspecDecoder.can_decode`** (`src/httpware/decoders/msgspec.py`): + +```python +def can_decode(self, model: type) -> bool: + try: + _get_msgspec_decoder(model) # cached msgspec.json.Decoder(model) + except (TypeError, msgspec.ValidationError): + return False + return True +``` + +`msgspec.json.Decoder(model)` raises `TypeError` for types msgspec can't build a decoder for — most notably `pydantic.BaseModel`. Succeeds for `Struct`, dataclass, primitive, `list[X]`, `dict[X, Y]`, etc. + +A new `_get_msgspec_decoder` module-level helper mirrors pydantic's `_get_adapter`: + +```python +@functools.lru_cache(maxsize=1024) +def _get_msgspec_decoder(model: type[T]) -> "msgspec.json.Decoder[T]": + return msgspec.json.Decoder(model) +``` + +Existing `MsgspecDecoder.decode` is rewritten to use the cached decoder rather than constructing per-call, matching pydantic's pattern. + +### Dispatch — `AsyncClient._dispatch_decoder` + +```python +def _dispatch_decoder(self, model: type) -> ResponseDecoder | None: + """Walk `_decoders` and return the first decoder claiming `model`, or None.""" + for decoder in self._decoders: + if decoder.can_decode(model): + return decoder + return None +``` + +Called by `send()` (both async and sync) when `response_model is not None`. Returns the matched decoder or `None`. The caller raises `MissingDecoderError` on `None`. + +**Dispatch order matters** — the list is the user's preference order. Both built-in decoders claim shared shapes (dataclass, primitive, generic) broadly; the first in the list wins for those. Native types route correctly regardless of order because each library rejects the other's native (pydantic's `TypeAdapter` rejects `Struct`; msgspec's `Decoder` rejects `BaseModel`). + +Default order: pydantic before msgspec, when both extras are installed. Consistent with the project's history (pydantic was the original primary). + +### Client state — `_decoders` replaces `_decoder` + +`AsyncClient` and `Client` attributes (`src/httpware/client.py:75`, `:793`): + +```python +# was: _decoder: ResponseDecoder +_decoders: tuple[ResponseDecoder, ...] +``` + +Init (`src/httpware/client.py:79`, `:797`): + +```python +def __init__( + self, + *, + base_url: str = "", + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + timeout: httpx2.Timeout | float | None = None, + limits: httpx2.Limits | None = None, + auth: httpx2.Auth | None = None, + httpx2_client: httpx2.AsyncClient | None = None, + decoders: Sequence[ResponseDecoder] | None = None, + middleware: Sequence[AsyncMiddleware] = (), +) -> None: + ... + self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() +``` + +`decoders=` is keyword-only and `Sequence[ResponseDecoder] | None`. `None` triggers the default; `()` / `[]` is a valid explicit "no decoders" — see Behavior matrix below. + +### Default decoders — `_build_default_decoders()` + +Replaces `_default_pydantic_decoder()` (`src/httpware/client.py:40`). Module-level helper: + +```python +def _build_default_decoders() -> tuple[ResponseDecoder, ...]: + decoders: list[ResponseDecoder] = [] + if import_checker.is_pydantic_installed: + from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design + decoders.append(PydanticDecoder()) + if import_checker.is_msgspec_installed: + from httpware.decoders.msgspec import MsgspecDecoder # noqa: PLC0415 — lazy by design + decoders.append(MsgspecDecoder()) + return tuple(decoders) +``` + +Lazy module imports preserve Seam C (`httpware ↔ optional extras` — `planning/engineering.md`): if `is_pydantic_installed` is False, `httpware.decoders.pydantic` is never imported, and `pydantic` itself never enters `sys.modules` via httpware. + +**Behavior matrix:** + +| Installed extras | `AsyncClient()` default `_decoders` | `AsyncClient()` raises? | `response_model=BaseModel` | `response_model=Struct` | `response_model=dict` | +|---|---|---|---|---|---| +| pydantic + msgspec | `(PydanticDecoder(), MsgspecDecoder())` | no | pydantic | msgspec | pydantic (first wins) | +| pydantic only | `(PydanticDecoder(),)` | no | pydantic | `MissingDecoderError` | pydantic | +| msgspec only | `(MsgspecDecoder(),)` | no | `MissingDecoderError` | msgspec | msgspec | +| neither | `()` | no | `MissingDecoderError` | `MissingDecoderError` | `MissingDecoderError` | +| neither, no `response_model=` ever | `()` | no | n/a | n/a | n/a — client works fine | + +`AsyncClient(decoders=[])` behaves identically to "neither installed" — explicit opt-out is honored; the user is telling the client "I will never use `response_model=`." + +## Send path — `.send()` with eager dispatch check + +`AsyncClient.send` (`src/httpware/client.py:147`) and `Client.send` (`:889`) gain a pre-flight check. Async form: + +```python +async def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, +) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + decoder: ResponseDecoder | None = None + if response_model is not None: + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError(model=response_model) + + response = await self._dispatch(request) + if decoder is None: + return response + try: + return decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc +``` + +Key change: `MissingDecoderError` fires **before** `await self._dispatch(request)`. Unlike `DecodeError` (data-dependent, only knowable post-response), `MissingDecoderError` is deterministic in `(response_model, self._decoders)`. Sending a request whose response cannot be decoded wastes a round-trip, may noise up retries / metrics, and gives the user a confusing trace through middleware before the real error surfaces. + +`send_with_response` (`client.py:162`, `:904`) gets the same pre-flight check. Both `AsyncClient` and `Client` mirror the change. + +The streaming path (`stream()`, `client.py:703`, `:1445`) is **unchanged**. It bypasses decoders entirely; `response_model=` is not a parameter; nothing routes through `_dispatch_decoder`. + +## Error contract — `MissingDecoderError` + +New sibling of `DecodeError` (`src/httpware/errors.py:226`), both under `ClientError`. + +```python +def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str: + if not registered_names: + hint = ( + "no decoders registered. Install `pip install httpware[pydantic]` " + "or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly." + ) + else: + joined = " + ".join(registered_names) + hint = ( + f"registered decoders ({joined}) all rejected it. " + f"Pass a custom decoder via decoders=[...]." + ) + return f"no decoder for response_model={model!r}: {hint}" + + +def _reconstruct_missing_decoder( + cls: "type[MissingDecoderError]", + model: type, + registered_names: tuple[str, ...], +) -> "MissingDecoderError": + return cls(model=model, registered_names=registered_names) + + +class MissingDecoderError(ClientError): + """Raised when response_model= is set but no registered decoder claims the model. + + Fires at .send() entry, BEFORE the HTTP call — no point sending a request + whose response cannot be decoded. Distinct from DecodeError, which means + the decoder ran and the payload was malformed. + """ + + model: type + registered_names: tuple[str, ...] + + def __init__(self, *, model: type, registered_names: tuple[str, ...]) -> None: + self.model = model + self.registered_names = registered_names + super().__init__(_missing_decoder_summary(model, registered_names)) + + def __reduce__(self) -> tuple[Any, ...]: + return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names)) +``` + +The client passes a snapshot of decoder class names at raise time: + +```python +raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), +) +``` + +**Why class-name snapshot, not the decoder instances?** Decoder instances may not be picklable in the general case (custom decoders can hold arbitrary state — caches, connections, closures). Keeping exception state to primitives (`type`, `tuple[str, ...]`) mirrors `BulkheadFullError` / `RetryBudgetExhaustedError` config-shape fields and guarantees pickle round-trips. The names are enough for both the user-facing message and structured logging. + +**Why not derive the message from `import_checker` flags?** That would produce a wrong hint when the user explicitly registered a custom decoder list (e.g. `decoders=[CustomDecoder()]` with both extras installed but custom decoder rejecting). The message must reflect what's *actually registered on this client*, not what's *installable in the environment*. + +**Exception tree placement.** `MissingDecoderError` is added to `__all__` in `src/httpware/__init__.py` next to `DecodeError`. `except ClientError` covers it. `except (DecodeError, MissingDecoderError)` separates the two corrective actions: +- `DecodeError` → fix data shape / model. +- `MissingDecoderError` → install an extra or register a decoder. + +## Tests + +Project requires 100% line coverage (`pyproject.toml:93` — `--cov-fail-under=100`). Every code path below must be exercised. + +### New test files + +**`tests/test_client_decoders_default.py`** — default resolution under varying extras state: + +| Case | Assertion | +|---|---| +| `AsyncClient()` with both extras installed | `_decoders == (PydanticDecoder(), MsgspecDecoder())` | +| `AsyncClient()` with pydantic only (`is_msgspec_installed` patched False) | `_decoders == (PydanticDecoder(),)` | +| `AsyncClient()` with msgspec only (`is_pydantic_installed` patched False) | `_decoders == (MsgspecDecoder(),)` | +| `AsyncClient()` with both patched False | `_decoders == ()`; no exception raised | +| `AsyncClient(decoders=[])` | `_decoders == ()`; explicit opt-out honored | +| `AsyncClient(decoders=[CustomDecoder()])` | `_decoders == (CustomDecoder(),)`; defaults NOT probed | +| `AsyncClient(decoders=[CustomDecoder()])` with both extras patched False | constructs ok; `import_checker` flags do not gate explicit decoders | +| Sync `Client` mirrors each case | (same six cases above) | + +Patching the import flags uses `monkeypatch.setattr(import_checker, "is_pydantic_installed", False)` — the existing test pattern for the otel partial-install spec. + +**`tests/test_client_dispatch.py`** — routing across multiple decoders: + +| Case | Assertion | +|---|---| +| `response_model=PydanticUser` with `decoders=[PydanticDecoder(), MsgspecDecoder()]` | decoded via pydantic; assert by patching `MsgspecDecoder.decode` to raise — confirms it's never called | +| `response_model=MsgspecUser` (Struct) with `decoders=[PydanticDecoder(), MsgspecDecoder()]` | decoded via msgspec; `PydanticDecoder.can_decode` returned False for Struct | +| `response_model=dict` with `decoders=[PydanticDecoder(), MsgspecDecoder()]` | decoded via pydantic (first wins for shared shapes) | +| `response_model=dict` with `decoders=[MsgspecDecoder(), PydanticDecoder()]` | decoded via msgspec (reversed order flips routing for shared shapes) | +| `response_model=list[PydanticUser]` | pydantic claims (TypeAdapter handles parameterized generics) | +| `response_model=MyDataclass` with both | pydantic claims (first in list) | +| `response_model=Foo` with `decoders=()` | `MissingDecoderError` raised; transport handler NEVER invoked (pre-flight check) | +| `response_model=Foo` where neither decoder claims Foo | `MissingDecoderError` raised; transport handler never invoked | +| Sync `Client` mirrors each case | (same eight cases above) | + +The "transport handler never invoked" assertion is the empirical proof that `MissingDecoderError` fires before the HTTP call. Pattern: wire a `httpx2.MockTransport(handler)` where `handler` either `pytest.fail("transport called")` or increments a counter; assert the counter is zero after the raise. Matches the existing `_client_with_payload` helper shape in `tests/test_client_response_model.py:14`. + +**`tests/test_errors_missing_decoder.py`** — exception shape and message hints: + +| Case | Assertion | +|---|---| +| `MissingDecoderError(model=Foo)` carries `.model is Foo` | direct attribute access | +| `str(exc)` includes `Foo.__name__` and a hint | regex / substring match | +| Hint says "install httpware[pydantic] or httpware[msgspec]" when `registered_names == ()` | substring match | +| Hint says "registered decoders (PydanticDecoder) all rejected it" when `registered_names == ("PydanticDecoder",)` | substring match | +| Hint says "registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it" when both names present | substring match | +| `.registered_names` is the tuple passed at construction | direct attribute access | +| `isinstance(MissingDecoderError(model=Foo), ClientError)` | tree placement check | +| `pickle.loads(pickle.dumps(exc)).model is Foo` and `.registered_names` round-trips | `__reduce__` round-trip | +| `MissingDecoderError` is exported from `httpware` top-level | `from httpware import MissingDecoderError` works | + +**`tests/test_decoders_can_decode.py`** — claim policies: + +| Decoder | model | Expected | Notes | +|---|---|---|---| +| PydanticDecoder | `class U(BaseModel): ...` | True | native | +| PydanticDecoder | `class U(Struct): ...` | False | TypeAdapter rejects | +| PydanticDecoder | `dict` | True | shared shape | +| PydanticDecoder | `list[int]` | True | parameterized generic | +| PydanticDecoder | `MyDataclass` | True | dataclass via TypeAdapter | +| PydanticDecoder | `int` | True | primitive | +| PydanticDecoder | `Foo \| None` | True | union | +| MsgspecDecoder | `class U(Struct): ...` | True | native | +| MsgspecDecoder | `class U(BaseModel): ...` | False | msgspec Decoder rejects | +| MsgspecDecoder | `dict` | True | shared shape | +| MsgspecDecoder | `list[int]` | True | parameterized generic | +| MsgspecDecoder | `MyDataclass` | True | dataclass via msgspec Decoder | +| MsgspecDecoder | `int` | True | primitive | + +Plus: `can_decode` is cached. Construct a `PydanticDecoder`, call `can_decode(BaseModelSubclass)` twice, assert `_get_adapter.cache_info().hits >= 1`. Same for `MsgspecDecoder._get_msgspec_decoder`. + +### Existing tests — update / delete + +**Delete:** + +- Any test asserting `AsyncClient()` raises `ImportError` when pydantic is uninstalled. The 0.3.0 fail-fast is gone. Search: `grep -r "_DEFAULT_DECODER_MISSING_MESSAGE\|_default_pydantic_decoder" tests/`. +- Direct unit tests of `_default_pydantic_decoder()`. + +**Update:** + +- Every `AsyncClient(decoder=...)` / `Client(decoder=...)` call site becomes `decoders=[...]`. Search: `grep -rn "decoder=" tests/` — expected ~10–20 call sites. +- `tests/decoders/test_pydantic.py` and `tests/decoders/test_msgspec.py`: add `can_decode` table tests; keep existing `decode` tests as-is. + +## Docs + +Decoder narrative is spread across two existing pages — no new docs page: + +- **`docs/index.md`** — the "First request" / install section currently shows `pip install httpware[pydantic] # PydanticDecoder (the default decoder path)`. Rewrite to: + 1. Frame extras as "install whichever decoder(s) you want; both can coexist." + 2. Replace any `decoder=` call sites with `decoders=[...]`. + 3. Add a short subsection on the `decoders=` list, the dispatch order, and a one-line example showing pydantic + msgspec mixed in the same client. +- **`docs/errors.md`** — the exception-tree page. Add `MissingDecoderError` as a sibling of `DecodeError` in the tree, with one bullet on the corrective action ("install an extra or register a decoder"). + +A separate "writing a custom `ResponseDecoder`" doc is out of scope for this spec — `ResponseDecoder` is a Protocol, the change to add `can_decode` is documented in release notes and the docstring on the Protocol itself. + +`README.md` examples updated wherever they use `decoder=` or imply a single decoder per client. Search: `grep -n "decoder=\|PydanticDecoder\|MsgspecDecoder" README.md`. + +No autodoc additions, no benchmarks, no migration guide — consistent with project docs philosophy ([user_docs_philosophy] in memory). Release notes carry the breaking-change call-out, not a dedicated migration page. + +## Release impact + +**Version:** 0.9.0 (minor — breaking surface change pre-1.0). + +**Release notes** in `planning/releases/0.9.0.md`: + +- **Breaking — `decoder=` kwarg removed.** Replaced with `decoders: Sequence[ResponseDecoder] | None = None`. Old code (`AsyncClient(decoder=PydanticDecoder())`) raises `TypeError`. Migration: `AsyncClient(decoders=[PydanticDecoder()])`. +- **Breaking — `ResponseDecoder` protocol gains `can_decode(model) -> bool`.** Custom decoder implementations must add the method. Common case: `def can_decode(self, model: type) -> bool: return True`. +- **Behavioral — `AsyncClient()` / `Client()` no longer raise `ImportError` when the `pydantic` extra is missing.** Failure now surfaces only when `response_model=` is used and no decoder claims the model, via the new `MissingDecoderError`. +- **New — mixed pydantic + msgspec models work in a single client.** `AsyncClient(decoders=[PydanticDecoder(), MsgspecDecoder()])`. Default when both extras are installed. +- **New — `MissingDecoderError`** under `ClientError`, exported from `httpware`. + +Tag and GitHub Release notes follow the existing bare-semver tag convention ([release_0_1_0_shipped] in memory). + +**Engineering doc update** — `planning/engineering.md` Seam B description is updated: + +- Old: "Called when `response_model` is provided. Signature: `decode(content: bytes, model: type[T]) -> T`." +- New: "Implementations expose `can_decode(model) -> bool` (dispatch predicate) and `decode(content, model) -> T` (the decode). The client holds a tuple `_decoders` and walks it in order on every `response_model=` use; first matching decoder wins. `MissingDecoderError` fires before the HTTP call when no decoder matches." From 59d8d03b56cc1b656f66d73a0697e510c2914734 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 9 Jun 2026 23:40:00 +0300 Subject: [PATCH 02/11] docs(plan): multi-decoder routing implementation plan Nine-task TDD sequence covering the AsyncClient/Client migration to `decoders=[...]`, the new MissingDecoderError, and the docs sweep. Phases A (new surfaces), B (atomic client refactors), C (new test files), D (docs). --- .../plans/2026-06-09-multi-decoder-plan.md | 1935 +++++++++++++++++ 1 file changed, 1935 insertions(+) create mode 100644 planning/plans/2026-06-09-multi-decoder-plan.md diff --git a/planning/plans/2026-06-09-multi-decoder-plan.md b/planning/plans/2026-06-09-multi-decoder-plan.md new file mode 100644 index 0000000..aa81c26 --- /dev/null +++ b/planning/plans/2026-06-09-multi-decoder-plan.md @@ -0,0 +1,1935 @@ +# Multi-Decoder Routing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the single-decoder slot on `AsyncClient`/`Client` with a type-dispatched `decoders=[...]` list, remove the 0.3.0 eager-import fail-fast for missing pydantic, and add `MissingDecoderError` (fires before the HTTP call when no registered decoder claims `response_model=`). + +**Architecture:** `ResponseDecoder` Protocol gains a `can_decode(model) -> bool` predicate. Both built-in decoders claim broadly — pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.json.Decoder(model)` probe — each rejects the other library's native type. The client holds `_decoders: tuple[ResponseDecoder, ...]`, resolved at `__init__` from installed extras (pydantic-first when both present) or from explicit `decoders=` kwarg. `send()` and `send_with_response()` run a pre-flight `_dispatch_decoder()` walk before the HTTP call; an empty walk raises `MissingDecoderError`. The kwarg is renamed `decoder=` → `decoders=` (clean cutover; pre-1.0). + +**Tech Stack:** Python 3.11+, `httpx2`, pydantic 2.x (optional extra), msgspec (optional extra), `pytest` + `pytest-asyncio` auto mode, `ty` for type checking, `ruff` for lint, `just` task runner. + +--- + +## Spec reference + +The validated spec is at `planning/specs/2026-06-09-multi-decoder-design.md`. Read it before starting. Decisions locked there and not re-debated here: + +- **Type-dispatched list, not per-call override.** Decoder list is composed at `__init__` and frozen. +- **Broad claim policy, ordering wins.** Each built-in claims everything its library can handle; first decoder in the list wins for shared shapes; native types route correctly because each library rejects the other's native. +- **Pydantic-first default ordering** when both extras installed. +- **Eager dispatch check at `.send()` entry** — `MissingDecoderError` fires before the HTTP request, not after. +- **Clean rename `decoder=` → `decoders=`** with no shim. Pre-1.0; bumps minor to 0.9.0. +- **`MissingDecoderError` carries `(model, registered_names)`** — class-name snapshot, not decoder instances (picklability). +- **`PydanticDecoder.__init__` still raises** `ImportError` when pydantic is missing — only the *default-construction* path stops calling it. Direct `PydanticDecoder()` usage when the extra is missing still errors. +- **No new stdlib `JsonDecoder`.** Out of scope; users with only `response_model=dict` install pydantic or msgspec. + +## Sequencing rationale + +The 100%-coverage gate (`pyproject.toml:93` — `--cov-fail-under=100`) forces atomic refactors. The two large tasks (Task 4 AsyncClient migration, Task 5 sync Client migration) MUST land in single commits — every existing test that reads `client._decoder` or passes `decoder=` breaks the moment the client is touched. + +Phase A (Tasks 1–3) adds new surfaces without touching client behavior. Phase B (Tasks 4–5) wires them in. Phase C (Tasks 6–7) adds dedicated integration test files for the new dispatch surface. Phase D (Tasks 8–9) finishes the docs and engineering note updates. + +After each task: run `just lint && just test`. The suite must be green before commit. + +--- + +## Phase A — New surfaces (additive) + +### Task 1: Add `can_decode` to Protocol and both built-in decoders + +**Files:** +- Modify: `src/httpware/decoders/__init__.py` +- Modify: `src/httpware/decoders/pydantic.py` +- Modify: `src/httpware/decoders/msgspec.py` +- Modify: `tests/test_decoders_pydantic.py` (add `can_decode` table tests) +- Modify: `tests/test_decoders_msgspec.py` (add `can_decode` table tests and cache assertion) + +Add the `can_decode(model) -> bool` predicate to the Protocol, with broad claim implementations in both concrete decoders. Add a cached `_get_msgspec_decoder` helper to `MsgspecDecoder` (mirroring pydantic's `_get_adapter`) so `can_decode` and `decode` share construction cost. + +- [ ] **Step 1: Write the failing tests for `PydanticDecoder.can_decode`** + +Append to `tests/test_decoders_pydantic.py`: + +```python +import msgspec + + +class _Struct(msgspec.Struct): + id: int + name: str + + +def test_pydantic_can_decode_basemodel() -> None: + assert PydanticDecoder().can_decode(User) is True + + +def test_pydantic_can_decode_dataclass() -> None: + assert PydanticDecoder().can_decode(UserDC) is True + + +def test_pydantic_can_decode_dict() -> None: + assert PydanticDecoder().can_decode(dict) is True + + +def test_pydantic_can_decode_list_of_models() -> None: + assert PydanticDecoder().can_decode(list[User]) is True + + +def test_pydantic_can_decode_primitive_int() -> None: + assert PydanticDecoder().can_decode(int) is True + + +def test_pydantic_can_decode_optional_int() -> None: + assert PydanticDecoder().can_decode(int | None) is True + + +def test_pydantic_rejects_msgspec_struct() -> None: + assert PydanticDecoder().can_decode(_Struct) is False + + +def test_pydantic_can_decode_uses_cache() -> None: + _get_adapter.cache_clear() + decoder = PydanticDecoder() + decoder.can_decode(User) + decoder.can_decode(User) + info = _get_adapter.cache_info() + assert info.hits >= 1 + assert info.misses == 1 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +uv run pytest tests/test_decoders_pydantic.py::test_pydantic_can_decode_basemodel -v +``` +Expected: FAIL with `AttributeError: 'PydanticDecoder' object has no attribute 'can_decode'`. + +- [ ] **Step 3: Add `can_decode` to the Protocol** + +Edit `src/httpware/decoders/__init__.py`: + +```python +"""ResponseDecoder protocol — the Client/AsyncClient ↔ ResponseDecoder seam (Seam B).""" + +from typing import Protocol, TypeVar, runtime_checkable + + +T = TypeVar("T") + + +@runtime_checkable +class ResponseDecoder(Protocol): + """Structural protocol every response-body decoder satisfies.""" + + def can_decode(self, model: type) -> bool: + """Return True iff this decoder claims responsibility for `model`. + + The client walks its `_decoders` tuple in order and picks the first + decoder whose `can_decode` returns True. Implementations should claim + every model type they can actually handle — broad is correct, because + list ordering encodes the caller's preference for shared shapes. + Native types of another library (e.g. `PydanticDecoder` vs + `msgspec.Struct`) MUST be rejected. + """ + ... + + def decode(self, content: bytes, model: type[T]) -> T: + """Decode `content` (raw response bytes) into an instance of `model`. + + Any exception raised by `decode` is wrapped by `Client.send` / + `AsyncClient.send` into `httpware.DecodeError`; implementers do not + need to raise `DecodeError` directly. + """ + ... + + +__all__ = ["ResponseDecoder"] +``` + +- [ ] **Step 4: Implement `PydanticDecoder.can_decode`** + +Edit `src/httpware/decoders/pydantic.py`. Leave the existing module docstring as-is for now (it accurately describes the still-live `_default_pydantic_decoder()` path; Task 5 updates the docstring after the helper is deleted). Add a `can_decode` method to the class: + +```python +class PydanticDecoder: + """Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`.""" + + def __init__(self) -> None: + if not import_checker.is_pydantic_installed: + raise ImportError(MISSING_DEPENDENCY_MESSAGE) + + def can_decode(self, model: type) -> bool: + """True iff pydantic can build a schema for `model`. + + Cached via `_get_adapter`; subsequent calls (including `decode`) reuse + the same `TypeAdapter` instance. Rejects `msgspec.Struct` subclasses — + pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when + building a schema for them. + """ + try: + _get_adapter(model) + except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no + return False + return True + + 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) +``` + +The `decode` method body is unchanged from the existing implementation; reproduced here so the engineer can see the file in its entirety after the edit. Only `can_decode` is genuinely new. + +- [ ] **Step 5: Run the pydantic tests; verify pass** + +```bash +uv run pytest tests/test_decoders_pydantic.py -v +``` +Expected: all green, including new `can_decode` tests. + +- [ ] **Step 6: Write the failing tests for `MsgspecDecoder.can_decode`** + +Append to `tests/test_decoders_msgspec.py`: + +```python +import dataclasses +import pydantic +from httpware.decoders.msgspec import MsgspecDecoder, _get_msgspec_decoder + + +class _PydanticUser(pydantic.BaseModel): + id: int + name: str + + +@dataclasses.dataclass +class _DC: + id: int + name: str + + +def test_msgspec_can_decode_struct() -> None: + assert MsgspecDecoder().can_decode(_Item) is True + + +def test_msgspec_can_decode_dataclass() -> None: + assert MsgspecDecoder().can_decode(_DC) is True + + +def test_msgspec_can_decode_dict() -> None: + assert MsgspecDecoder().can_decode(dict) is True + + +def test_msgspec_can_decode_list_of_structs() -> None: + assert MsgspecDecoder().can_decode(list[_Item]) is True + + +def test_msgspec_can_decode_primitive_int() -> None: + assert MsgspecDecoder().can_decode(int) is True + + +def test_msgspec_rejects_pydantic_basemodel() -> None: + assert MsgspecDecoder().can_decode(_PydanticUser) is False + + +def test_msgspec_can_decode_uses_cache() -> None: + _get_msgspec_decoder.cache_clear() + decoder = MsgspecDecoder() + decoder.can_decode(_Item) + decoder.can_decode(_Item) + info = _get_msgspec_decoder.cache_info() + assert info.hits >= 1 + assert info.misses == 1 +``` + +`_Item` is already defined at the top of `tests/test_decoders_msgspec.py`; reuse it. + +- [ ] **Step 7: Run the msgspec tests to verify they fail** + +```bash +uv run pytest tests/test_decoders_msgspec.py -k can_decode -v +``` +Expected: FAIL with `ImportError: cannot import name '_get_msgspec_decoder'` (because the helper doesn't exist yet). + +- [ ] **Step 8: Implement `_get_msgspec_decoder` cache and `MsgspecDecoder.can_decode`** + +Rewrite `src/httpware/decoders/msgspec.py`: + +```python +"""MsgspecDecoder — opt-in ResponseDecoder backed by a cached msgspec.json.Decoder.""" + +import functools +from typing import TypeVar + +from httpware._internal import import_checker + + +if import_checker.is_msgspec_installed: + import msgspec + + +MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]" + +T = TypeVar("T") + + +@functools.lru_cache(maxsize=1024) +def _get_msgspec_decoder(model: type[T]) -> "msgspec.json.Decoder[T]": + return msgspec.json.Decoder(model) + + +class MsgspecDecoder: + """Decode raw response bytes via a cached `msgspec.json.Decoder(model)`. + + Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing + this module without the extra works (the `msgspec` import is guarded by a + `find_spec` check), but instantiating the decoder raises `ImportError`. + """ + + def __init__(self) -> None: + if not import_checker.is_msgspec_installed: + raise ImportError(MISSING_DEPENDENCY_MESSAGE) + + def can_decode(self, model: type) -> bool: + """True iff msgspec can build a Decoder for `model`. + + Cached via `_get_msgspec_decoder`; subsequent calls reuse the same + Decoder instance. Rejects `pydantic.BaseModel` subclasses — msgspec + raises `TypeError` when building a Decoder for them. + """ + try: + _get_msgspec_decoder(model) + except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no + return False + return True + + def decode(self, content: bytes, model: type[T]) -> T: + """Validate `content` as JSON against `model` in a single parse pass.""" + try: + decoder = _get_msgspec_decoder(model) + except TypeError: + decoder = msgspec.json.Decoder(model) + return decoder.decode(content) +``` + +- [ ] **Step 9: Run the full decoder test suite** + +```bash +uv run pytest tests/test_decoders_pydantic.py tests/test_decoders_msgspec.py -v +``` +Expected: all green. + +- [ ] **Step 10: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green; 100% coverage maintained. + +- [ ] **Step 11: Commit** + +```bash +git add src/httpware/decoders/__init__.py src/httpware/decoders/pydantic.py src/httpware/decoders/msgspec.py tests/test_decoders_pydantic.py tests/test_decoders_msgspec.py +git commit -m "feat(decoders): add can_decode predicate to ResponseDecoder protocol" +``` + +--- + +### Task 2: Add `MissingDecoderError` and export it + +**Files:** +- Modify: `src/httpware/errors.py` +- Modify: `src/httpware/__init__.py` +- Modify: `tests/test_errors.py` +- Modify: `tests/test_public_api.py` + +Add the new exception below `DecodeError` in the hierarchy, export from the top-level package, cover via unit tests. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_errors.py`: + +```python +import pickle + +from httpware import MissingDecoderError + + +class _Foo: + pass + + +def test_missing_decoder_error_carries_model() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=()) + assert exc.model is _Foo + + +def test_missing_decoder_error_carries_registered_names() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=("PydanticDecoder",)) + assert exc.registered_names == ("PydanticDecoder",) + + +def test_missing_decoder_error_no_registered_message() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=()) + msg = str(exc) + assert "no decoders registered" in msg + assert "httpware[pydantic]" in msg + assert "httpware[msgspec]" in msg + + +def test_missing_decoder_error_single_registered_message() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=("PydanticDecoder",)) + assert "registered decoders (PydanticDecoder) all rejected" in str(exc) + + +def test_missing_decoder_error_two_registered_message() -> None: + exc = MissingDecoderError( + model=_Foo, + registered_names=("PydanticDecoder", "MsgspecDecoder"), + ) + assert "registered decoders (PydanticDecoder + MsgspecDecoder) all rejected" in str(exc) + + +def test_missing_decoder_error_is_client_error() -> None: + from httpware import ClientError + + exc = MissingDecoderError(model=_Foo, registered_names=()) + assert isinstance(exc, ClientError) + + +def test_missing_decoder_error_pickle_roundtrip() -> None: + exc = MissingDecoderError( + model=_Foo, + registered_names=("PydanticDecoder", "MsgspecDecoder"), + ) + revived = pickle.loads(pickle.dumps(exc)) + assert revived.model is _Foo + assert revived.registered_names == ("PydanticDecoder", "MsgspecDecoder") +``` + +Append to `tests/test_public_api.py`: + +```python +def test_missing_decoder_error_exported() -> None: + import httpware + + assert "MissingDecoderError" in httpware.__all__ + assert httpware.MissingDecoderError.__module__ == "httpware.errors" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +uv run pytest tests/test_errors.py::test_missing_decoder_error_carries_model -v +``` +Expected: FAIL with `ImportError: cannot import name 'MissingDecoderError' from 'httpware'`. + +- [ ] **Step 3: Add `MissingDecoderError` to `errors.py`** + +Append to `src/httpware/errors.py`: + +```python +def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str: + if not registered_names: + hint = ( + "no decoders registered. Install `pip install httpware[pydantic]` " + "or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly." + ) + else: + joined = " + ".join(registered_names) + hint = ( + f"registered decoders ({joined}) all rejected it. " + f"Pass a custom decoder via decoders=[...]." + ) + return f"no decoder for response_model={model!r}: {hint}" + + +def _reconstruct_missing_decoder( + cls: "type[MissingDecoderError]", + model: type, + registered_names: tuple[str, ...], +) -> "MissingDecoderError": + return cls(model=model, registered_names=registered_names) + + +class MissingDecoderError(ClientError): + """Raised when response_model= is set but no registered decoder claims the model. + + Fires at .send() entry, BEFORE the HTTP call — no point sending a request + whose response cannot be decoded. Distinct from DecodeError, which means + the decoder ran and the payload was malformed. + """ + + model: type + registered_names: tuple[str, ...] + + def __init__(self, *, model: type, registered_names: tuple[str, ...]) -> None: + self.model = model + self.registered_names = registered_names + super().__init__(_missing_decoder_summary(model, registered_names)) + + def __reduce__(self) -> tuple[Any, ...]: + return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names)) +``` + +- [ ] **Step 4: Export from top-level `httpware`** + +Edit `src/httpware/__init__.py`. Add `MissingDecoderError` to the `from httpware.errors import (...)` block (alphabetical position after `InternalServerError`, before `NetworkError`): + +```python +from httpware.errors import ( + STATUS_TO_EXCEPTION, + BadRequestError, + BulkheadFullError, + ClientError, + ClientStatusError, + ConflictError, + DecodeError, + ForbiddenError, + InternalServerError, + MissingDecoderError, + NetworkError, + NotFoundError, + RateLimitedError, + RetryBudgetExhaustedError, + ServerStatusError, + ServiceUnavailableError, + StatusError, + TimeoutError, # noqa: A004 + TransportError, + UnauthorizedError, + UnprocessableEntityError, +) +``` + +Add `"MissingDecoderError"` to `__all__` (alphabetical position after `"InternalServerError"`, before `"Middleware"`): + +```python +__all__ = [ + "STATUS_TO_EXCEPTION", + "AsyncBulkhead", + "AsyncClient", + "AsyncMiddleware", + "AsyncNext", + "AsyncRetry", + "BadRequestError", + "Bulkhead", + "BulkheadFullError", + "Client", + "ClientError", + "ClientStatusError", + "ConflictError", + "DecodeError", + "ForbiddenError", + "InternalServerError", + "Middleware", + "MissingDecoderError", + "NetworkError", + "Next", + "NotFoundError", + "RateLimitedError", + "ResponseDecoder", + "Retry", + "RetryBudget", + "RetryBudgetExhaustedError", + "ServerStatusError", + "ServiceUnavailableError", + "StatusError", + "TimeoutError", + "TransportError", + "UnauthorizedError", + "UnprocessableEntityError", + "after_response", + "async_after_response", + "async_before_request", + "async_on_error", + "before_request", + "on_error", +] +``` + +- [ ] **Step 5: Run the error tests** + +```bash +uv run pytest tests/test_errors.py tests/test_public_api.py -v +``` +Expected: all green. + +- [ ] **Step 6: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green; 100% coverage maintained. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/errors.py src/httpware/__init__.py tests/test_errors.py tests/test_public_api.py +git commit -m "feat(errors): add MissingDecoderError raised by future multi-decoder dispatch" +``` + +--- + +### Task 3: Add `_build_default_decoders()` helper to `client.py` + +**Files:** +- Modify: `src/httpware/client.py` +- Modify: `tests/test_client_construction.py` + +Introduce the helper that probes installed extras and returns the default decoder tuple. Not yet wired into either client class — that happens in Tasks 4 and 5. The existing `_default_pydantic_decoder()` stays put alongside it until Task 5. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_client_construction.py`: + +```python +from unittest.mock import patch + +from httpware.client import _build_default_decoders +from httpware.decoders.pydantic import PydanticDecoder +from httpware.decoders.msgspec import MsgspecDecoder + + +def test_build_default_decoders_both_extras_installed() -> None: + result = _build_default_decoders() + assert len(result) == 2 # noqa: PLR2004 + assert isinstance(result[0], PydanticDecoder) + assert isinstance(result[1], MsgspecDecoder) + + +def test_build_default_decoders_pydantic_only() -> None: + with patch("httpware._internal.import_checker.is_msgspec_installed", False): + result = _build_default_decoders() + assert len(result) == 1 + assert isinstance(result[0], PydanticDecoder) + + +def test_build_default_decoders_msgspec_only() -> None: + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + result = _build_default_decoders() + assert len(result) == 1 + assert isinstance(result[0], MsgspecDecoder) + + +def test_build_default_decoders_neither_installed() -> None: + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + result = _build_default_decoders() + assert result == () + + +def test_build_default_decoders_returns_tuple() -> None: + result = _build_default_decoders() + assert isinstance(result, tuple) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +uv run pytest tests/test_client_construction.py -k build_default_decoders -v +``` +Expected: FAIL with `ImportError: cannot import name '_build_default_decoders' from 'httpware.client'`. + +- [ ] **Step 3: Add `_build_default_decoders()` to `client.py`** + +Edit `src/httpware/client.py`. After the existing `_default_pydantic_decoder` definition (around line 45), insert the new helper: + +```python +def _build_default_decoders() -> tuple[ResponseDecoder, ...]: + """Construct the default decoder tuple based on installed extras. + + Pydantic-first when both extras are present; either-only when only one is + installed; empty tuple when neither is installed. Imports the concrete + decoder modules lazily so missing extras never trip `find_spec`-guarded + import paths. Called by `AsyncClient.__init__` and `Client.__init__` when + `decoders=None` (the default). + """ + decoders: list[ResponseDecoder] = [] + if import_checker.is_pydantic_installed: + from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design (Seam C) + + decoders.append(PydanticDecoder()) + if import_checker.is_msgspec_installed: + from httpware.decoders.msgspec import MsgspecDecoder # noqa: PLC0415 — lazy by design (Seam C) + + decoders.append(MsgspecDecoder()) + return tuple(decoders) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +uv run pytest tests/test_client_construction.py -k build_default_decoders -v +``` +Expected: all green. + +- [ ] **Step 5: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green; 100% coverage maintained (the new helper is fully covered by the four parametrized cases above). + +- [ ] **Step 6: Commit** + +```bash +git add src/httpware/client.py tests/test_client_construction.py +git commit -m "feat(client): add _build_default_decoders helper for installed-extras probe" +``` + +--- + +## Phase B — Wire it into the client + +### Task 4: Migrate `AsyncClient` to `decoders=` + dispatch + pre-flight check + +**Files:** +- Modify: `src/httpware/client.py` (AsyncClient class only) +- Modify: `tests/test_client_construction.py` (existing `_decoder` / `decoder=` references) +- Modify: `tests/test_client_response_model.py` (if any `decoder=` references) +- Modify: `tests/test_decoders_msgspec.py:66` (`decoder=MsgspecDecoder()` → `decoders=[MsgspecDecoder()]`) +- Modify: `tests/test_optional_extras_pydantic_missing.py` (invert async-client assertions; switch `decoder=` → `decoders=`) + +This is the atomic refactor for the async surface. It must be one commit because the 100% coverage gate rejects any half-state. The sync `Client` keeps using `_default_pydantic_decoder()` until Task 5. + +- [ ] **Step 1: Inventory the existing call sites that will break** + +```bash +grep -n "decoder=\|client._decoder\b\|self\._decoder\b" src/httpware/client.py tests/ | grep -v __pycache__ +``` + +Expected hits (must all be updated in this task for the AsyncClient code paths): +- `src/httpware/client.py:75` — attribute annotation `_decoder: ResponseDecoder` +- `src/httpware/client.py:90` — kwarg `decoder: ResponseDecoder | None = None` +- `src/httpware/client.py:126` — assignment `self._decoder = ... _default_pydantic_decoder()` +- `src/httpware/client.py:158` — `self._decoder.decode(...)` in `send` +- `src/httpware/client.py:179` — `self._decoder.decode(...)` in `send_with_response` +- `tests/test_client_construction.py:53` — `assert isinstance(client._decoder, PydanticDecoder)` +- `tests/test_client_construction.py:61` — `AsyncClient(decoder=_Stub())` +- `tests/test_client_construction.py:62` — `assert isinstance(client._decoder, _Stub)` +- `tests/test_decoders_msgspec.py:66` — `decoder=MsgspecDecoder()` +- `tests/test_optional_extras_pydantic_missing.py` — async-client cases + +(Sync `Client` lines `:793`, `:808`, `:844`, `:900`, `:921` and `tests/test_client_sync.py:64,73-74` are deliberately left for Task 5.) + +- [ ] **Step 2: Write the failing tests for the new AsyncClient behavior** + +Edit `tests/test_client_construction.py`. Replace the existing `test_default_decoder_is_pydantic_decoder` and `test_explicit_decoder_is_honored` with their migrated versions: + +```python +def test_default_decoders_includes_pydantic_when_installed() -> None: + client = AsyncClient() + assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 + + +def test_explicit_decoders_is_honored() -> None: + class _Stub: + def can_decode(self, model: type) -> bool: # noqa: ARG002 + return True + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover + return None + + stub = _Stub() + client = AsyncClient(decoders=[stub]) + assert client._decoders == (stub,) # noqa: SLF001 + + +def test_empty_decoders_is_honored() -> None: + client = AsyncClient(decoders=[]) + assert client._decoders == () # noqa: SLF001 +``` + +Append a new test for pre-flight `MissingDecoderError`: + +```python +async def test_missing_decoder_raised_before_http_call() -> None: + """response_model with no claiming decoder raises before the transport is invoked.""" + import httpx2 + import pytest + from httpware import MissingDecoderError + + def handler(_: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + with pytest.raises(MissingDecoderError) as exc_info: + await client.get("https://example.test/x", response_model=_Foo) + assert exc_info.value.model is _Foo + assert exc_info.value.registered_names == () +``` + +Edit `tests/test_decoders_msgspec.py`, line 66 region: + +```python + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[MsgspecDecoder()], + ) +``` + +Edit `tests/test_optional_extras_pydantic_missing.py`. Replace the two `*_default_decoder_raises_when_pydantic_missing` tests and the `*_accepts_explicit_decoder_without_pydantic` tests with: + +```python +def test_async_client_no_pydantic_constructs_without_raising() -> None: + """AsyncClient() with pydantic missing must not raise — lazy default policy.""" + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = AsyncClient() + assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 + + +def test_async_client_accepts_explicit_decoders_without_pydantic() -> None: + """An explicit decoders= list is honored regardless of pydantic install state.""" + fake = _FakeDecoder() + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = AsyncClient(decoders=[fake]) + assert client._decoders == (fake,) # noqa: SLF001 +``` + +Update `_FakeDecoder` in that file to satisfy the new Protocol: + +```python +class _FakeDecoder: + """Test stand-in for ResponseDecoder; never called at runtime.""" + + def can_decode(self, model: type) -> bool: # noqa: ARG002 + return True + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — name pinned by ResponseDecoder protocol + return model() # pragma: no cover +``` + +Leave the sync `Client` cases (`test_sync_client_*`) UNCHANGED — they still exercise the old `_default_pydantic_decoder()` path until Task 5. Same for `test_pydantic_decoder_init_raises_when_pydantic_missing` (it tests `PydanticDecoder()` directly, which still raises). + +- [ ] **Step 3: Run the tests to verify they fail** + +```bash +uv run pytest tests/test_client_construction.py tests/test_optional_extras_pydantic_missing.py tests/test_decoders_msgspec.py -v +``` +Expected: failures referencing `_decoders` attribute missing, `decoders=` kwarg unknown, etc. + +- [ ] **Step 4: Refactor `AsyncClient` in `src/httpware/client.py`** + +Update imports at top of `client.py` — add `MissingDecoderError`: + +```python +from httpware.errors import DecodeError, MissingDecoderError, TransportError +``` + +Replace the AsyncClient attribute block (currently `client.py:73-77`): + +```python +class AsyncClient: + """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware.""" + + _httpx2_client: httpx2.AsyncClient + _owns_client: bool + _decoders: tuple[ResponseDecoder, ...] + _user_middleware: tuple[AsyncMiddleware, ...] + _dispatch: AsyncNext +``` + +Replace the `__init__` signature and body (currently `client.py:79-128`). Change the `decoder` kwarg to `decoders` and the assignment: + +```python + def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API + self, + *, + base_url: str = "", + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + timeout: httpx2.Timeout | float | None = None, + limits: httpx2.Limits | None = None, + auth: httpx2.Auth | None = None, + httpx2_client: httpx2.AsyncClient | None = None, + decoders: Sequence[ResponseDecoder] | None = None, + middleware: Sequence[AsyncMiddleware] = (), + ) -> None: + if httpx2_client is not None: + forwarded = { + "base_url": base_url, + "headers": headers, + "params": params, + "cookies": cookies, + "timeout": timeout, + "limits": limits, + "auth": auth, + } + if any(value not in (None, "") for value in forwarded.values()): + raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) + self._httpx2_client = httpx2_client + self._owns_client = False + else: + kwargs: dict[str, typing.Any] = {} + if base_url: + kwargs["base_url"] = base_url + if headers is not None: + kwargs["headers"] = headers + if params is not None: + kwargs["params"] = params + if cookies is not None: + kwargs["cookies"] = cookies + if timeout is not None: + kwargs["timeout"] = timeout + if limits is not None: + kwargs["limits"] = limits + if auth is not None: + kwargs["auth"] = auth + self._httpx2_client = httpx2.AsyncClient(**kwargs) + self._owns_client = True + + self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() + self._user_middleware = tuple(middleware) + self._dispatch = compose_async(self._user_middleware, self._terminal) +``` + +Add a private dispatcher method on `AsyncClient` (insert immediately before `_terminal`, around `client.py:130`): + +```python + def _dispatch_decoder(self, model: type) -> ResponseDecoder | None: + """Walk `_decoders` and return the first decoder claiming `model`, or None.""" + for decoder in self._decoders: + if decoder.can_decode(model): + return decoder + return None +``` + +Rewrite `AsyncClient.send` (currently `client.py:147-160`): + +```python + async def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, + ) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + decoder: ResponseDecoder | None = None + if response_model is not None: + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + + response = await self._dispatch(request) + if decoder is None: + return response + try: + return decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc +``` + +Rewrite `AsyncClient.send_with_response` (currently `client.py:162-182`): + +```python + async def send_with_response( + self, + request: httpx2.Request, + *, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send `request` through the middleware chain; return (response, decoded). + + Use this when you need response metadata (headers, status, request URL) + AND a typed body — most commonly for Link-header pagination. For the + body-only case, prefer ``send(request, response_model=...)``. + + Not for streaming responses — decodes ``response.content``, which + requires the body to be fully read. Use ``stream()`` for streaming. + """ + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + + response = await self._dispatch(request) + try: + decoded = decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc + return response, decoded +``` + +- [ ] **Step 5: Run the targeted test files** + +```bash +uv run pytest tests/test_client_construction.py tests/test_client_response_model.py tests/test_client_send_with_response.py tests/test_decoders_msgspec.py tests/test_optional_extras_pydantic_missing.py -v +``` +Expected: green. + +- [ ] **Step 6: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green; 100% coverage maintained. + +If coverage drops below 100, the most likely cause is dead branches in `send_with_response` or a missed test for `_dispatch_decoder` returning `None`. The new `test_missing_decoder_raised_before_http_call` covers the `send` path; add a parallel test for `send_with_response` if coverage flags it: + +```python +async def test_send_with_response_raises_missing_decoder_before_http_call() -> None: + import httpx2 + import pytest + from httpware import MissingDecoderError + + def handler(_: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + request = client.build_request("GET", "https://example.test/x") + with pytest.raises(MissingDecoderError): + await client.send_with_response(request, response_model=_Foo) +``` + +Add this to `tests/test_client_send_with_response.py`. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/client.py tests/test_client_construction.py tests/test_client_response_model.py tests/test_client_send_with_response.py tests/test_decoders_msgspec.py tests/test_optional_extras_pydantic_missing.py +git commit -m "feat(client)!: AsyncClient takes decoders=[...] with type-dispatched routing" +``` + +The `!` after `feat(client)` flags the breaking surface change for release-notes tooling. + +--- + +### Task 5: Migrate sync `Client` (mirror Task 4) and delete the old default helper + +**Files:** +- Modify: `src/httpware/client.py` (sync Client class + delete `_default_pydantic_decoder` and `_DEFAULT_DECODER_MISSING_MESSAGE`) +- Modify: `src/httpware/decoders/pydantic.py` (update module docstring to remove the stale `client.py:_default_pydantic_decoder()` reference) +- Modify: `tests/test_client_sync.py` (existing `_decoder` / `decoder=` references) +- Modify: `tests/test_optional_extras_pydantic_missing.py` (mirror sync invert) +- Modify: `tests/test_client_send_with_response_sync.py` (`MissingDecoderError` sync case) + +Now that the AsyncClient is fully migrated, repeat the surgery on the sync class and delete the now-unused `_default_pydantic_decoder`. + +- [ ] **Step 1: Write the failing tests** + +Edit `tests/test_client_sync.py`. Replace `test_default_decoder_is_pydantic_decoder` and `test_explicit_decoder_is_honored` with their migrated versions: + +```python +def test_default_decoders_includes_pydantic_when_installed() -> None: + client = Client() + assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 + client.close() + + +def test_explicit_decoders_is_honored() -> None: + class _Stub: + def can_decode(self, model: type) -> bool: # noqa: ARG002 + return True + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover + return None + + stub = _Stub() + client = Client(decoders=[stub]) + assert client._decoders == (stub,) # noqa: SLF001 + client.close() + + +def test_empty_decoders_is_honored() -> None: + client = Client(decoders=[]) + assert client._decoders == () # noqa: SLF001 + client.close() + + +def test_sync_missing_decoder_raised_before_http_call() -> None: + import httpx2 + import pytest + from httpware import MissingDecoderError + + def handler(_: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + with pytest.raises(MissingDecoderError) as exc_info: + client.get("https://example.test/x", response_model=_Foo) + assert exc_info.value.model is _Foo + assert exc_info.value.registered_names == () + client.close() +``` + +In `tests/test_optional_extras_pydantic_missing.py`, replace the sync-client cases: + +```python +def test_sync_client_no_pydantic_constructs_without_raising() -> None: + """Client() with pydantic missing must not raise — lazy default policy.""" + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = Client() + assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 + client.close() + + +def test_sync_client_accepts_explicit_decoders_without_pydantic() -> None: + fake = _FakeDecoder() + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = Client(decoders=[fake]) + assert client._decoders == (fake,) # noqa: SLF001 + client.close() +``` + +Append to `tests/test_client_send_with_response_sync.py`: + +```python +def test_sync_send_with_response_raises_missing_decoder_before_http_call() -> None: + import httpx2 + import pytest + from httpware import Client, MissingDecoderError + + def handler(_: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + request = client.build_request("GET", "https://example.test/x") + with pytest.raises(MissingDecoderError): + client.send_with_response(request, response_model=_Foo) + client.close() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +uv run pytest tests/test_client_sync.py tests/test_client_send_with_response_sync.py tests/test_optional_extras_pydantic_missing.py -v +``` +Expected: failures referencing `_decoders` and `decoders=` on the sync class. + +- [ ] **Step 3: Refactor sync `Client` in `src/httpware/client.py`** + +Update the `Client` attribute block (currently `client.py:791-795`): + +```python +class Client: + """Sync HTTP client: thin wrapper around httpx2 with typed decoding and middleware.""" + + _httpx2_client: httpx2.Client + _owns_client: bool + _decoders: tuple[ResponseDecoder, ...] + _user_middleware: tuple[Middleware, ...] + _dispatch: Next +``` + +Update the `Client.__init__` signature and body (currently `client.py:797-846`): + +```python + def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API + self, + *, + base_url: str = "", + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + timeout: httpx2.Timeout | float | None = None, + limits: httpx2.Limits | None = None, + auth: httpx2.Auth | None = None, + httpx2_client: httpx2.Client | None = None, + decoders: Sequence[ResponseDecoder] | None = None, + middleware: Sequence[Middleware] = (), + ) -> None: + if httpx2_client is not None: + forwarded = { + "base_url": base_url, + "headers": headers, + "params": params, + "cookies": cookies, + "timeout": timeout, + "limits": limits, + "auth": auth, + } + if any(value not in (None, "") for value in forwarded.values()): + raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) + self._httpx2_client = httpx2_client + self._owns_client = False + else: + kwargs: dict[str, typing.Any] = {} + if base_url: + kwargs["base_url"] = base_url + if headers is not None: + kwargs["headers"] = headers + if params is not None: + kwargs["params"] = params + if cookies is not None: + kwargs["cookies"] = cookies + if timeout is not None: + kwargs["timeout"] = timeout + if limits is not None: + kwargs["limits"] = limits + if auth is not None: + kwargs["auth"] = auth + self._httpx2_client = httpx2.Client(**kwargs) + self._owns_client = True + + self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() + self._user_middleware = tuple(middleware) + self._dispatch = compose(self._user_middleware, self._terminal) +``` + +Add `_dispatch_decoder` on `Client` (insert immediately before `_terminal`, around `client.py:848`): + +```python + def _dispatch_decoder(self, model: type) -> ResponseDecoder | None: + """Walk `_decoders` and return the first decoder claiming `model`, or None.""" + for decoder in self._decoders: + if decoder.can_decode(model): + return decoder + return None +``` + +Rewrite `Client.send` (currently `client.py:889-902`): + +```python + def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, + ) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + decoder: ResponseDecoder | None = None + if response_model is not None: + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + + response = self._dispatch(request) + if decoder is None: + return response + try: + return decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc +``` + +Rewrite `Client.send_with_response` (currently `client.py:904-924`): + +```python + def send_with_response( + self, + request: httpx2.Request, + *, + response_model: type[T], + ) -> tuple[httpx2.Response, T]: + """Send `request` through the middleware chain; return (response, decoded). + + Use this when you need response metadata (headers, status, request URL) + AND a typed body — most commonly for Link-header pagination. For the + body-only case, prefer ``send(request, response_model=...)``. + + Not for streaming responses — decodes ``response.content``, which + requires the body to be fully read. Use ``stream()`` for streaming. + """ + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + + response = self._dispatch(request) + try: + decoded = decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc + return response, decoded +``` + +Delete `_default_pydantic_decoder` and `_DEFAULT_DECODER_MISSING_MESSAGE` (currently `client.py:33-45`). Both are now unused. + +- [ ] **Step 4: Update the now-stale docstring in `src/httpware/decoders/pydantic.py`** + +The module docstring still references `client.py:_default_pydantic_decoder()`, which this task just deleted. Replace the docstring with: + +```python +"""PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder. + +Requires the `pydantic` extra: `pip install httpware[pydantic]`. Constructing +`PydanticDecoder()` directly when pydantic is not installed raises ImportError. +The default-decoder path in `client.py:_build_default_decoders()` skips this +class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does +not trip the ImportError when the user is not using `response_model=`. +""" +``` + +- [ ] **Step 5: Run the sync tests** + +```bash +uv run pytest tests/test_client_sync.py tests/test_client_send_with_response_sync.py tests/test_optional_extras_pydantic_missing.py -v +``` +Expected: green. + +- [ ] **Step 6: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green; 100% coverage maintained. + +If coverage drops, the most likely cause is leftover unused code from `_default_pydantic_decoder` deletion. Search the diff: + +```bash +git diff src/httpware/client.py | grep -E '^-' | grep -i decoder +``` + +Confirm nothing references the deleted helper. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/client.py src/httpware/decoders/pydantic.py tests/test_client_sync.py tests/test_client_send_with_response_sync.py tests/test_optional_extras_pydantic_missing.py +git commit -m "feat(client)!: sync Client takes decoders=[...] with type-dispatched routing" +``` + +--- + +## Phase C — New integration test files + +### Task 6: `tests/test_client_decoders_default.py` + +**Files:** +- Create: `tests/test_client_decoders_default.py` + +Dedicated coverage of the default-decoder resolution matrix from the spec, exercising both async and sync clients across all extras-installed combinations. + +- [ ] **Step 1: Create the test file** + +Write `tests/test_client_decoders_default.py`: + +```python +"""Default decoder resolution under varying extras-installed states. + +Covers the behavior matrix in planning/specs/2026-06-09-multi-decoder-design.md +— `AsyncClient()` / `Client()` resolve `decoders=None` against the +`import_checker` flags at __init__ time. +""" + +from unittest.mock import patch + +from httpware import AsyncClient, Client +from httpware.decoders.msgspec import MsgspecDecoder +from httpware.decoders.pydantic import PydanticDecoder + + +def test_async_default_both_extras_installed() -> None: + client = AsyncClient() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder, MsgspecDecoder) + + +def test_async_default_pydantic_only() -> None: + with patch("httpware._internal.import_checker.is_msgspec_installed", False): + client = AsyncClient() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder,) + + +def test_async_default_msgspec_only() -> None: + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = AsyncClient() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (MsgspecDecoder,) + + +def test_async_default_neither_installed() -> None: + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + client = AsyncClient() + assert client._decoders == () # noqa: SLF001 + + +def test_async_empty_explicit_decoders() -> None: + client = AsyncClient(decoders=[]) + assert client._decoders == () # noqa: SLF001 + + +def test_async_explicit_decoders_skip_default_probe() -> None: + class _Custom: + def can_decode(self, model: type) -> bool: # noqa: ARG002 + return True + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover + return None + + custom = _Custom() + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + client = AsyncClient(decoders=[custom]) + assert client._decoders == (custom,) # noqa: SLF001 + + +def test_sync_default_both_extras_installed() -> None: + client = Client() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder, MsgspecDecoder) + client.close() + + +def test_sync_default_pydantic_only() -> None: + with patch("httpware._internal.import_checker.is_msgspec_installed", False): + client = Client() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder,) + client.close() + + +def test_sync_default_msgspec_only() -> None: + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = Client() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (MsgspecDecoder,) + client.close() + + +def test_sync_default_neither_installed() -> None: + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + client = Client() + assert client._decoders == () # noqa: SLF001 + client.close() + + +def test_sync_empty_explicit_decoders() -> None: + client = Client(decoders=[]) + assert client._decoders == () # noqa: SLF001 + client.close() +``` + +- [ ] **Step 2: Run the new test file** + +```bash +uv run pytest tests/test_client_decoders_default.py -v +``` +Expected: all green (the runtime is already in place after Tasks 4 and 5). + +- [ ] **Step 3: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green; 100% coverage maintained. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_client_decoders_default.py +git commit -m "test(client): cover default-decoder resolution matrix for both clients" +``` + +--- + +### Task 7: `tests/test_client_dispatch.py` + +**Files:** +- Create: `tests/test_client_dispatch.py` + +Dedicated coverage of the dispatch routing — which decoder handles which model under varying decoder lists, including the order-flips-shared-shape and native-types-route-correctly cases. + +- [ ] **Step 1: Create the test file** + +Write `tests/test_client_dispatch.py`: + +```python +"""Dispatch routing across multiple registered decoders. + +Covers the routing examples in planning/specs/2026-06-09-multi-decoder-design.md +§ Architecture — native types route via their library regardless of order, +shared shapes route to the first decoder in the list. +""" + +import dataclasses +from http import HTTPStatus + +import httpx2 +import msgspec +import pydantic +import pytest + +from httpware import AsyncClient, Client, MissingDecoderError +from httpware.decoders.msgspec import MsgspecDecoder +from httpware.decoders.pydantic import PydanticDecoder + + +class _PydanticUser(pydantic.BaseModel): + id: int + name: str + + +class _MsgspecUser(msgspec.Struct): + id: int + name: str + + +@dataclasses.dataclass +class _DC: + id: int + name: str + + +def _async_client_with_body(payload: bytes, decoders: list) -> AsyncClient: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, content=payload, request=request) + + transport = httpx2.MockTransport(handler) + return AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=decoders, + ) + + +def _sync_client_with_body(payload: bytes, decoders: list) -> Client: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, content=payload, request=request) + + transport = httpx2.MockTransport(handler) + return Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=decoders, + ) + + +async def test_async_basemodel_routes_to_pydantic() -> None: + client = _async_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=_PydanticUser) + assert type(result) is _PydanticUser + assert result.id == 1 + + +async def test_async_struct_routes_to_msgspec() -> None: + client = _async_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=_MsgspecUser) + assert type(result) is _MsgspecUser + assert result.id == 1 + + +async def test_async_dict_routes_to_first_decoder() -> None: + """Shared shape: first decoder in the list wins.""" + pyd = PydanticDecoder() + msg = MsgspecDecoder() + client = _async_client_with_body(b'{"a": 1}', decoders=[pyd, msg]) + result = await client.get("https://example.test/x", response_model=dict[str, int]) + assert type(result) is dict + assert result == {"a": 1} + + +async def test_async_dict_routes_to_msgspec_when_first() -> None: + """Reversed list flips routing for shared shapes.""" + client = _async_client_with_body( + b'{"a": 1}', + decoders=[MsgspecDecoder(), PydanticDecoder()], + ) + result = await client.get("https://example.test/x", response_model=dict[str, int]) + assert result == {"a": 1} + + +async def test_async_dataclass_routes_to_first_decoder() -> None: + client = _async_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=_DC) + assert type(result) is _DC + assert result.id == 1 + + +async def test_async_list_of_basemodel_routes_to_pydantic() -> None: + client = _async_client_with_body( + b'[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}]', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=list[_PydanticUser]) + assert len(result) == 2 # noqa: PLR2004 + assert all(type(item) is _PydanticUser for item in result) + + +async def test_async_missing_decoder_with_empty_list() -> None: + """Empty decoder list and response_model= raises before HTTP call.""" + + def handler(_: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be invoked") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[], + ) + with pytest.raises(MissingDecoderError) as exc_info: + await client.get("https://example.test/x", response_model=_PydanticUser) + assert exc_info.value.registered_names == () + + +async def test_async_missing_decoder_when_none_claim() -> None: + """Registered decoders that all reject the model raise MissingDecoderError.""" + + class _Stub: + def can_decode(self, model: type) -> bool: # noqa: ARG002 + return False + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover + return None + + def handler(_: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be invoked") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[_Stub()], + ) + with pytest.raises(MissingDecoderError) as exc_info: + await client.get("https://example.test/x", response_model=_PydanticUser) + assert exc_info.value.registered_names == ("_Stub",) + + +def test_sync_basemodel_routes_to_pydantic() -> None: + client = _sync_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = client.get("https://example.test/x", response_model=_PydanticUser) + assert type(result) is _PydanticUser + client.close() + + +def test_sync_struct_routes_to_msgspec() -> None: + client = _sync_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = client.get("https://example.test/x", response_model=_MsgspecUser) + assert type(result) is _MsgspecUser + client.close() + + +def test_sync_dict_routes_to_first_decoder() -> None: + client = _sync_client_with_body( + b'{"a": 1}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = client.get("https://example.test/x", response_model=dict[str, int]) + assert result == {"a": 1} + client.close() + + +def test_sync_dict_routes_to_msgspec_when_first() -> None: + client = _sync_client_with_body( + b'{"a": 1}', + decoders=[MsgspecDecoder(), PydanticDecoder()], + ) + result = client.get("https://example.test/x", response_model=dict[str, int]) + assert result == {"a": 1} + client.close() + + +def test_sync_missing_decoder_with_empty_list() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be invoked") + + transport = httpx2.MockTransport(handler) + client = Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=[], + ) + with pytest.raises(MissingDecoderError): + client.get("https://example.test/x", response_model=_PydanticUser) + client.close() +``` + +- [ ] **Step 2: Run the new test file** + +```bash +uv run pytest tests/test_client_dispatch.py -v +``` +Expected: all green. + +- [ ] **Step 3: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green; 100% coverage maintained. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_client_dispatch.py +git commit -m "test(client): cover type-dispatched decoder routing across both clients" +``` + +--- + +## Phase D — Docs and engineering notes + +### Task 8: Update `README.md`, `docs/index.md`, and `docs/errors.md` + +**Files:** +- Modify: `README.md` +- Modify: `docs/index.md` +- Modify: `docs/errors.md` + +User-facing narrative for the new `decoders=` shape and the new `MissingDecoderError`. + +- [ ] **Step 1: Update `README.md`** + +`README.md:23` currently says: + +```markdown +`AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing. +``` + +Replace with: + +```markdown +`AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. `AsyncClient()` never raises on missing extras — failure is deferred to the first `response_model=` call, where `MissingDecoderError` fires *before* the HTTP request if no registered decoder claims the model. +``` + +Search for any other `decoder=` mentions in `README.md` and rename to `decoders=[...]`: + +```bash +grep -n "decoder=" README.md +``` + +Update each hit to use the plural list form. + +- [ ] **Step 2: Update `docs/index.md`** + +Current install/quickstart blurb (around the install section): + +```markdown +pip install httpware[pydantic] # PydanticDecoder (the default decoder path) +pip install httpware[msgspec] # MsgspecDecoder +``` + +Replace with: + +```markdown +pip install httpware[pydantic] # PydanticDecoder — handles BaseModel + dataclasses + primitives + generics +pip install httpware[msgspec] # MsgspecDecoder — handles Struct + dataclasses + primitives + generics +pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec +``` + +Find and update any `decoder=` call sites in `docs/index.md`: + +```bash +grep -n "decoder=" docs/index.md +``` + +Replace each with `decoders=[...]`. + +Add a short subsection on the dispatch order (insert after the existing "Typed decoding via `response_model=`" subsection): + +````markdown +### Decoder dispatch + +When `response_model=` is set, the client walks `decoders` in order and picks +the first decoder whose `can_decode(model)` returns `True`. Both built-in +decoders claim broadly within their library; the ordering encodes your +preference for shared shapes (`dict`, `list[Foo]`, dataclasses, primitives): + +```python +# pydantic-first (the default when both extras are installed): +# - BaseModel -> pydantic +# - Struct -> msgspec +# - dict, list -> pydantic (first in list) +AsyncClient(decoders=[PydanticDecoder(), MsgspecDecoder()]) + +# msgspec-first — same native routing, but shared shapes go to msgspec: +# - BaseModel -> pydantic +# - Struct -> msgspec +# - dict, list -> msgspec +AsyncClient(decoders=[MsgspecDecoder(), PydanticDecoder()]) +``` + +If no registered decoder claims your `response_model`, the call raises +`MissingDecoderError` *before* the HTTP request — see the +[Errors reference](errors.md#missingdecodererror). +```` + +- [ ] **Step 3: Update `docs/errors.md`** + +Find the tree section. The existing entry for `DecodeError` looks like: + +```markdown +- **Decode errors** — `DecodeError`, raised when `response_model=` decoding fails (HTTP call itself succeeded). +``` + +Add a sibling bullet next to it: + +```markdown +- **Decode errors** — `DecodeError`, raised when `response_model=` decoding fails (HTTP call itself succeeded). `MissingDecoderError`, raised when no registered decoder claims the `response_model=` type — fires *before* the HTTP call. +``` + +Then in the per-exception reference body, add a section for `MissingDecoderError` next to the `DecodeError` entry: + +```markdown +### `MissingDecoderError` + +Raised by `send()` / `send_with_response()` / verb methods when `response_model=` is set but no registered decoder claims the model. Carries: + +- `model: type` — the `response_model=` value that wasn't claimed. +- `registered_names: tuple[str, ...]` — class names of the registered decoders that all rejected the model. Empty tuple means no decoders were registered. + +Corrective action depends on the message hint: + +- `no decoders registered. Install pip install httpware[pydantic] or pip install httpware[msgspec], or pass decoders=[...] explicitly.` — install an extra or pass an explicit decoder list. +- `registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it.` — your `response_model` type is exotic enough that neither built-in claims it. Pass a custom `ResponseDecoder` via `decoders=[...]`. + +Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic is sent. +``` + +- [ ] **Step 4: Verify rendered docs build (if mkdocs is set up locally)** + +```bash +ls mkdocs.yml 2>/dev/null && uv run --extra docs mkdocs build --strict 2>&1 | tail -20 || echo "mkdocs not configured locally — skip" +``` + +If mkdocs builds, check the build output for `WARNING` lines on the new content. + +- [ ] **Step 5: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green (no code change in this task). + +- [ ] **Step 6: Commit** + +```bash +git add README.md docs/index.md docs/errors.md +git commit -m "docs: rewrite decoder narrative for multi-decoder routing" +``` + +--- + +### Task 9: Update `planning/engineering.md` Seam B description + +**Files:** +- Modify: `planning/engineering.md` + +Update the canonical engineering reference so future contributors find the new Seam B contract. + +- [ ] **Step 1: Locate the Seam B section** + +```bash +grep -n "Seam B" planning/engineering.md +``` + +The current Seam B description (from the spec preamble) reads: + +> Seam B — Client/AsyncClient ↔ ResponseDecoder — called when response_model is provided. Signature: decode(content: bytes, model: type[T]) -> T. Implementations of both send methods call the decoder identically. + +- [ ] **Step 2: Replace with the new Seam B description** + +Find the matching paragraph (likely in a numbered list near a heading like "Protocol seams" or "Internal seams") and replace it with: + +```markdown +2. **Seam B** — `Client`/`AsyncClient` ↔ `ResponseDecoder` list — `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime. The Protocol exposes two methods: + + - `can_decode(model: type) -> bool` — predicate used at send-time to walk `_decoders` and pick the first claiming decoder. Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.json.Decoder(model)` probe); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. + - `decode(content: bytes, model: type[T]) -> T` — the decode itself. Any exception is wrapped as `httpware.DecodeError` at the seam. + + When `response_model=` is set and no decoder claims it, both `send` and `send_with_response` raise `MissingDecoderError` BEFORE the HTTP call. The default `decoders=None` resolves via `client.py:_build_default_decoders()` against installed extras. +``` + +- [ ] **Step 3: Run lint and full test suite** + +```bash +just lint && just test +``` +Expected: green (no code change). + +- [ ] **Step 4: Commit** + +```bash +git add planning/engineering.md +git commit -m "docs(planning): update Seam B for multi-decoder routing" +``` + +--- + +## Self-review checklist + +After the final commit, verify the implementation against the spec. + +- [ ] **Spec coverage:** Every section of `planning/specs/2026-06-09-multi-decoder-design.md` is implemented. + - Protocol shape (`can_decode`): Task 1. + - Claim policies (PydanticDecoder, MsgspecDecoder): Task 1. + - `_dispatch_decoder` on AsyncClient and Client: Tasks 4, 5. + - `_build_default_decoders` helper: Task 3. + - Behavior matrix (extras-installed combinations): Tasks 4, 5, 6. + - Send path with pre-flight `MissingDecoderError`: Tasks 4, 5. + - `MissingDecoderError` shape (model + registered_names + pickle): Task 2. + - Public API export: Task 2. + - Tests new files: Tasks 6, 7. + - Docs (README + index + errors): Task 8. + - Engineering doc Seam B: Task 9. + - `decoder=` → `decoders=` rename: Tasks 4, 5. + - Deletion of `_default_pydantic_decoder` / `_DEFAULT_DECODER_MISSING_MESSAGE`: Task 5. + +- [ ] **No placeholders:** `grep -nE 'TBD|TODO|FIXME|xxx|placeholder' planning/plans/2026-06-09-multi-decoder-plan.md`. Expected: zero hits (the word "fixture" is fine; the words above are not). + +- [ ] **Type consistency:** Names used across tasks are stable — `_decoders` (not `_decoder_list`), `_dispatch_decoder` (not `_choose_decoder`), `_build_default_decoders` (not `_default_decoders`), `registered_names` (not `registered`). + +- [ ] **Final suite:** `just lint && just test` is green with 100% coverage. + +- [ ] **Release notes:** Plan does NOT cover writing release notes — that's a separate ship step. Confirm `planning/releases/0.9.0.md` is created during the release flow, not here. From fff1fbafcfe8e22abaf0b8612c8d0147a466c055 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 9 Jun 2026 23:52:12 +0300 Subject: [PATCH 03/11] feat(decoders): add can_decode predicate to ResponseDecoder protocol --- src/httpware/decoders/__init__.py | 12 +++ src/httpware/decoders/msgspec.py | 41 +++++++-- src/httpware/decoders/pydantic.py | 14 +++ tests/test_client_construction.py | 3 + tests/test_client_sync.py | 3 + tests/test_decoders_msgspec.py | 90 ++++++++++++++++++- tests/test_decoders_pydantic.py | 44 +++++++++ .../test_optional_extras_pydantic_missing.py | 3 + 8 files changed, 204 insertions(+), 6 deletions(-) diff --git a/src/httpware/decoders/__init__.py b/src/httpware/decoders/__init__.py index 0321d38..296875e 100644 --- a/src/httpware/decoders/__init__.py +++ b/src/httpware/decoders/__init__.py @@ -10,6 +10,18 @@ class ResponseDecoder(Protocol): """Structural protocol every response-body decoder satisfies.""" + def can_decode(self, model: type) -> bool: + """Return True iff this decoder claims responsibility for `model`. + + The client walks its `_decoders` tuple in order and picks the first + decoder whose `can_decode` returns True. Implementations should claim + every model type they can actually handle — broad is correct, because + list ordering encodes the caller's preference for shared shapes. + Native types of another library (e.g. `PydanticDecoder` vs + `msgspec.Struct`) MUST be rejected. + """ + ... + def decode(self, content: bytes, model: type[T]) -> T: """Decode `content` (raw response bytes) into an instance of `model`. diff --git a/src/httpware/decoders/msgspec.py b/src/httpware/decoders/msgspec.py index be05b7c..16a8321 100644 --- a/src/httpware/decoders/msgspec.py +++ b/src/httpware/decoders/msgspec.py @@ -1,5 +1,6 @@ -"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode.""" +"""MsgspecDecoder — opt-in ResponseDecoder backed by a cached msgspec.json.Decoder.""" +import functools from typing import TypeVar from httpware._internal import import_checker @@ -14,19 +15,49 @@ T = TypeVar("T") +@functools.lru_cache(maxsize=1024) +def _get_msgspec_decoder(model: type[T]) -> "msgspec.json.Decoder[T]": + return msgspec.json.Decoder(model) + + class MsgspecDecoder: - """Decode raw response bytes via `msgspec.json.decode(content, type=model)`. + """Decode raw response bytes via a cached `msgspec.json.Decoder(model)`. Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing this module without the extra works (the `msgspec` import is guarded by a - `find_spec` check), but instantiating the decoder raises `ImportError` with - the install hint. + `find_spec` check), but instantiating the decoder raises `ImportError`. """ def __init__(self) -> None: if not import_checker.is_msgspec_installed: raise ImportError(MISSING_DEPENDENCY_MESSAGE) + def can_decode(self, model: type) -> bool: + """Return True iff msgspec natively understands `model`. + + Cached via `_get_msgspec_decoder`; subsequent calls reuse the same + Decoder instance. Rejects `pydantic.BaseModel` subclasses — msgspec + will *build* a Decoder for them (falling back to a generic + `CustomType`) but cannot actually decode them without a `dec_hook`, + so we use `msgspec.inspect.type_info` to detect the fallback and + refuse to claim the model. + """ + try: + info = msgspec.inspect.type_info(model) + except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no + return False + if isinstance(info, msgspec.inspect.CustomType): + return False + try: + _get_msgspec_decoder(model) + except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no + return False + return True + def decode(self, content: bytes, model: type[T]) -> T: """Validate `content` as JSON against `model` in a single parse pass.""" - return msgspec.json.decode(content, type=model) + try: + decoder = _get_msgspec_decoder(model) + except TypeError: + decoder = msgspec.json.Decoder(model) + return decoder.decode(content) diff --git a/src/httpware/decoders/pydantic.py b/src/httpware/decoders/pydantic.py index c2997f1..07fdbdc 100644 --- a/src/httpware/decoders/pydantic.py +++ b/src/httpware/decoders/pydantic.py @@ -36,6 +36,20 @@ def __init__(self) -> None: if not import_checker.is_pydantic_installed: raise ImportError(MISSING_DEPENDENCY_MESSAGE) + def can_decode(self, model: type) -> bool: + """Return True iff pydantic can build a schema for `model`. + + Cached via `_get_adapter`; subsequent calls (including `decode`) reuse + the same `TypeAdapter` instance. Rejects `msgspec.Struct` subclasses — + pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when + building a schema for them. + """ + try: + _get_adapter(model) + except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no + return False + return True + def decode(self, content: bytes, model: type[T]) -> T: """Validate `content` as JSON against `model` in a single parse pass.""" try: diff --git a/tests/test_client_construction.py b/tests/test_client_construction.py index b580746..35d3658 100644 --- a/tests/test_client_construction.py +++ b/tests/test_client_construction.py @@ -55,6 +55,9 @@ def test_default_decoder_is_pydantic_decoder() -> None: def test_explicit_decoder_is_honored() -> None: class _Stub: + def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover + return True + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover return None diff --git a/tests/test_client_sync.py b/tests/test_client_sync.py index bcc3c13..1a8c5c9 100644 --- a/tests/test_client_sync.py +++ b/tests/test_client_sync.py @@ -67,6 +67,9 @@ def test_default_decoder_is_pydantic_decoder() -> None: def test_explicit_decoder_is_honored() -> None: class _Stub: + def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover + return True + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover return None diff --git a/tests/test_decoders_msgspec.py b/tests/test_decoders_msgspec.py index c030902..8b77a30 100644 --- a/tests/test_decoders_msgspec.py +++ b/tests/test_decoders_msgspec.py @@ -1,15 +1,18 @@ """Unit tests for httpware.decoders.msgspec.MsgspecDecoder.""" +import dataclasses from http import HTTPStatus +from unittest.mock import patch import httpx2 import msgspec +import pydantic import pytest from httpware import AsyncClient, DecodeError from httpware._internal import import_checker from httpware.decoders import ResponseDecoder -from httpware.decoders.msgspec import MsgspecDecoder +from httpware.decoders.msgspec import MsgspecDecoder, _get_msgspec_decoder class _Item(msgspec.Struct): @@ -70,3 +73,88 @@ def handler(request: httpx2.Request) -> httpx2.Response: exc = exc_info.value assert exc.model is _Item assert isinstance(exc.original, (msgspec.DecodeError, msgspec.ValidationError)) + + +class _PydanticUser(pydantic.BaseModel): + id: int + name: str + + +@dataclasses.dataclass +class _DC: + id: int + name: str + + +@pytest.fixture(autouse=True) +def _clear_msgspec_cache() -> None: + _get_msgspec_decoder.cache_clear() + + +def test_msgspec_can_decode_struct() -> None: + assert MsgspecDecoder().can_decode(_Item) is True + + +def test_msgspec_can_decode_dataclass() -> None: + assert MsgspecDecoder().can_decode(_DC) is True + + +def test_msgspec_can_decode_dict() -> None: + assert MsgspecDecoder().can_decode(dict) is True + + +def test_msgspec_can_decode_list_of_structs() -> None: + assert MsgspecDecoder().can_decode(list[_Item]) is True + + +def test_msgspec_can_decode_primitive_int() -> None: + assert MsgspecDecoder().can_decode(int) is True + + +def test_msgspec_rejects_pydantic_basemodel() -> None: + assert MsgspecDecoder().can_decode(_PydanticUser) is False + + +def test_msgspec_can_decode_uses_cache() -> None: + _get_msgspec_decoder.cache_clear() + decoder = MsgspecDecoder() + decoder.can_decode(_Item) + decoder.can_decode(_Item) + info = _get_msgspec_decoder.cache_info() + assert info.hits >= 1 + assert info.misses == 1 + + +def test_can_decode_returns_false_when_type_info_raises() -> None: + """`type_info` failures (unrecognized type) are treated as a soft 'no'.""" + with patch( + "httpware.decoders.msgspec.msgspec.inspect.type_info", + side_effect=TypeError("unknown"), + ): + assert MsgspecDecoder().can_decode(_Item) is False + + +def test_can_decode_returns_false_when_decoder_build_raises() -> None: + """A `_get_msgspec_decoder` failure after type_info-classification is a soft 'no'.""" + _get_msgspec_decoder.cache_clear() + with patch( + "httpware.decoders.msgspec._get_msgspec_decoder", + side_effect=TypeError("cannot build decoder"), + ): + assert MsgspecDecoder().can_decode(_Item) is False + + +def test_unhashable_model_falls_back_to_uncached_decoder() -> None: + """Unhashable `model` falls back to a direct uncached `msgspec.json.Decoder`. + + Mirrors `PydanticDecoder`'s unhashable-fallback test: when `_get_msgspec_decoder` + raises `TypeError` (e.g., an unhashable parameterized type), `decode` bypasses + the cache so the user-visible error is `msgspec`'s own decode error, not a + `functools`-internal `TypeError`. + """ + with patch( + "httpware.decoders.msgspec._get_msgspec_decoder", + side_effect=TypeError("unhashable type"), + ): + result = MsgspecDecoder().decode(b"42", int) + assert result == 42 # noqa: PLR2004 diff --git a/tests/test_decoders_pydantic.py b/tests/test_decoders_pydantic.py index 71de516..a847c1d 100644 --- a/tests/test_decoders_pydantic.py +++ b/tests/test_decoders_pydantic.py @@ -5,6 +5,7 @@ import dataclasses from unittest.mock import patch +import msgspec import pydantic import pytest @@ -176,3 +177,46 @@ def test_malformed_payload_raises_validation_error(payload: bytes, model: type) """ with pytest.raises(pydantic.ValidationError): PydanticDecoder().decode(payload, model) + + +class _Struct(msgspec.Struct): + id: int + name: str + + +def test_pydantic_can_decode_basemodel() -> None: + assert PydanticDecoder().can_decode(User) is True + + +def test_pydantic_can_decode_dataclass() -> None: + assert PydanticDecoder().can_decode(UserDC) is True + + +def test_pydantic_can_decode_dict() -> None: + assert PydanticDecoder().can_decode(dict) is True + + +def test_pydantic_can_decode_list_of_models() -> None: + assert PydanticDecoder().can_decode(list[User]) is True + + +def test_pydantic_can_decode_primitive_int() -> None: + assert PydanticDecoder().can_decode(int) is True + + +def test_pydantic_can_decode_optional_int() -> None: + assert PydanticDecoder().can_decode(int | None) is True # ty: ignore[invalid-argument-type] + + +def test_pydantic_rejects_msgspec_struct() -> None: + assert PydanticDecoder().can_decode(_Struct) is False + + +def test_pydantic_can_decode_uses_cache() -> None: + _get_adapter.cache_clear() + decoder = PydanticDecoder() + decoder.can_decode(User) + decoder.can_decode(User) + info = _get_adapter.cache_info() + assert info.hits >= 1 + assert info.misses == 1 diff --git a/tests/test_optional_extras_pydantic_missing.py b/tests/test_optional_extras_pydantic_missing.py index a2bf733..73234f6 100644 --- a/tests/test_optional_extras_pydantic_missing.py +++ b/tests/test_optional_extras_pydantic_missing.py @@ -17,6 +17,9 @@ class _FakeDecoder: """Test stand-in for ResponseDecoder; never called at runtime.""" + def can_decode(self, model: type) -> bool: # noqa: ARG002 — name pinned by ResponseDecoder protocol + return True # pragma: no cover + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — name pinned by ResponseDecoder protocol return model() # pragma: no cover From a46b72a6fcf734d2a3b5a4673259c07707f9f834 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 00:05:57 +0300 Subject: [PATCH 04/11] feat(errors): add MissingDecoderError raised by future multi-decoder dispatch --- src/httpware/__init__.py | 2 ++ src/httpware/errors.py | 40 +++++++++++++++++++++++++++++++ tests/test_errors.py | 52 ++++++++++++++++++++++++++++++++++++++++ tests/test_public_api.py | 6 +++++ 4 files changed, 100 insertions(+) diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index 88f18df..f1599f9 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -12,6 +12,7 @@ DecodeError, ForbiddenError, InternalServerError, + MissingDecoderError, NetworkError, NotFoundError, RateLimitedError, @@ -57,6 +58,7 @@ "ForbiddenError", "InternalServerError", "Middleware", + "MissingDecoderError", "NetworkError", "Next", "NotFoundError", diff --git a/src/httpware/errors.py b/src/httpware/errors.py index 7d03180..baedda4 100644 --- a/src/httpware/errors.py +++ b/src/httpware/errors.py @@ -254,3 +254,43 @@ def __reduce__(self) -> tuple[Any, ...]: _reconstruct_decode_error, (type(self), self.response, self.model, self.original), ) + + +def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str: + if not registered_names: + hint = ( + "no decoders registered. Install `pip install httpware[pydantic]` " + "or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly." + ) + else: + joined = " + ".join(registered_names) + hint = f"registered decoders ({joined}) all rejected it. Pass a custom decoder via decoders=[...]." + return f"no decoder for response_model={model!r}: {hint}" + + +def _reconstruct_missing_decoder( + cls: "type[MissingDecoderError]", + model: type, + registered_names: tuple[str, ...], +) -> "MissingDecoderError": + return cls(model=model, registered_names=registered_names) + + +class MissingDecoderError(ClientError): + """Raised when response_model= is set but no registered decoder claims the model. + + Fires at .send() entry, BEFORE the HTTP call — no point sending a request + whose response cannot be decoded. Distinct from DecodeError, which means + the decoder ran and the payload was malformed. + """ + + model: type + registered_names: tuple[str, ...] + + def __init__(self, *, model: type, registered_names: tuple[str, ...]) -> None: + self.model = model + self.registered_names = registered_names + super().__init__(_missing_decoder_summary(model, registered_names)) + + def __reduce__(self) -> tuple[Any, ...]: + return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names)) diff --git a/tests/test_errors.py b/tests/test_errors.py index 0d39669..4a48b0f 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -18,6 +18,7 @@ DecodeError, ForbiddenError, InternalServerError, + MissingDecoderError, NetworkError, NotFoundError, RateLimitedError, @@ -305,3 +306,54 @@ def test_decode_error_pickleable() -> None: assert isinstance(restored.original, ValueError) assert str(restored.original) == "bad payload" assert restored.response.status_code == HTTPStatus.OK + + +class _Foo: + pass + + +def test_missing_decoder_error_carries_model() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=()) + assert exc.model is _Foo + + +def test_missing_decoder_error_carries_registered_names() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=("PydanticDecoder",)) + assert exc.registered_names == ("PydanticDecoder",) + + +def test_missing_decoder_error_no_registered_message() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=()) + msg = str(exc) + assert "no decoders registered" in msg + assert "httpware[pydantic]" in msg + assert "httpware[msgspec]" in msg + + +def test_missing_decoder_error_single_registered_message() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=("PydanticDecoder",)) + assert "registered decoders (PydanticDecoder) all rejected" in str(exc) + + +def test_missing_decoder_error_two_registered_message() -> None: + exc = MissingDecoderError( + model=_Foo, + registered_names=("PydanticDecoder", "MsgspecDecoder"), + ) + assert "registered decoders (PydanticDecoder + MsgspecDecoder) all rejected" in str(exc) + + +def test_missing_decoder_error_is_client_error() -> None: + exc = MissingDecoderError(model=_Foo, registered_names=()) + assert isinstance(exc, ClientError) + + +def test_missing_decoder_error_pickle_roundtrip() -> None: + exc = MissingDecoderError( + model=_Foo, + registered_names=("PydanticDecoder", "MsgspecDecoder"), + ) + revived = pickle.loads(pickle.dumps(exc)) # noqa: S301 + assert isinstance(revived, MissingDecoderError) + assert revived.model is _Foo + assert revived.registered_names == ("PydanticDecoder", "MsgspecDecoder") diff --git a/tests/test_public_api.py b/tests/test_public_api.py index fe469b3..1f1426e 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -44,6 +44,7 @@ def test_expected_exports() -> None: "ForbiddenError", "InternalServerError", "Middleware", + "MissingDecoderError", "NetworkError", "Next", "NotFoundError", @@ -71,3 +72,8 @@ def test_expected_exports() -> None: assert expected == actual, ( f"__all__ mismatch:\n missing from __all__: {expected - actual}\n unexpected in __all__: {actual - expected}" ) + + +def test_missing_decoder_error_exported() -> None: + assert "MissingDecoderError" in httpware.__all__ + assert httpware.MissingDecoderError.__module__ == "httpware.errors" From 2c82f7fd4f0567f0b254089a0ba47906652bcb90 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 01:54:28 +0300 Subject: [PATCH 05/11] feat(client): add _build_default_decoders helper for installed-extras probe --- src/httpware/client.py | 21 +++++++++++++++++ tests/test_client_construction.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/httpware/client.py b/src/httpware/client.py index d341609..cffaa0b 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -45,6 +45,27 @@ def _default_pydantic_decoder() -> ResponseDecoder: return PydanticDecoder() +def _build_default_decoders() -> tuple[ResponseDecoder, ...]: + """Construct the default decoder tuple based on installed extras. + + Pydantic-first when both extras are present; either-only when only one is + installed; empty tuple when neither is installed. Imports the concrete + decoder modules lazily so missing extras never trip `find_spec`-guarded + import paths. Called by `AsyncClient.__init__` and `Client.__init__` when + `decoders=None` (the default). + """ + decoders: list[ResponseDecoder] = [] + if import_checker.is_pydantic_installed: + from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design (Seam C) + + decoders.append(PydanticDecoder()) + if import_checker.is_msgspec_installed: + from httpware.decoders.msgspec import MsgspecDecoder # noqa: PLC0415 — lazy by design (Seam C) + + decoders.append(MsgspecDecoder()) + return tuple(decoders) + + @contextlib.asynccontextmanager async def _httpx2_exception_mapper() -> AsyncIterator[None]: """Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream().""" diff --git a/tests/test_client_construction.py b/tests/test_client_construction.py index 35d3658..2f9776f 100644 --- a/tests/test_client_construction.py +++ b/tests/test_client_construction.py @@ -1,9 +1,13 @@ """Tests for AsyncClient construction and ownership semantics.""" +from unittest.mock import patch + import httpx2 import pytest from httpware import AsyncClient +from httpware.client import _build_default_decoders +from httpware.decoders.msgspec import MsgspecDecoder from httpware.decoders.pydantic import PydanticDecoder @@ -90,3 +94,38 @@ async def __call__(self, request, next) -> httpx2.Response: # noqa: A002, ANN00 client = AsyncClient(middleware=(_Tag(),)) assert client._user_middleware == (client._user_middleware[0],) # noqa: SLF001 assert len(client._user_middleware) == 1 # noqa: SLF001 + + +def test_build_default_decoders_both_extras_installed() -> None: + result = _build_default_decoders() + assert len(result) == 2 # noqa: PLR2004 + assert isinstance(result[0], PydanticDecoder) + assert isinstance(result[1], MsgspecDecoder) + + +def test_build_default_decoders_pydantic_only() -> None: + with patch("httpware._internal.import_checker.is_msgspec_installed", False): + result = _build_default_decoders() + assert len(result) == 1 + assert isinstance(result[0], PydanticDecoder) + + +def test_build_default_decoders_msgspec_only() -> None: + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + result = _build_default_decoders() + assert len(result) == 1 + assert isinstance(result[0], MsgspecDecoder) + + +def test_build_default_decoders_neither_installed() -> None: + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + result = _build_default_decoders() + assert result == () + + +def test_build_default_decoders_returns_tuple() -> None: + result = _build_default_decoders() + assert isinstance(result, tuple) From 46e6d7ebdf7f6f7d3a12a73848ad38ede7e240d3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 09:11:53 +0300 Subject: [PATCH 06/11] feat(client)!: AsyncClient takes decoders=[...] with type-dispatched routing --- src/httpware/client.py | 38 ++++++++++++++---- tests/test_client_construction.py | 39 ++++++++++++++++--- tests/test_client_send_with_response.py | 20 +++++++++- tests/test_decoders_msgspec.py | 2 +- .../test_optional_extras_pydantic_missing.py | 19 +++++---- 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/src/httpware/client.py b/src/httpware/client.py index cffaa0b..75ed912 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -16,7 +16,7 @@ _raise_on_status_error, ) from httpware.decoders import ResponseDecoder -from httpware.errors import DecodeError, TransportError +from httpware.errors import DecodeError, MissingDecoderError, TransportError from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next from httpware.middleware.chain import compose, compose_async @@ -93,7 +93,7 @@ class AsyncClient: _httpx2_client: httpx2.AsyncClient _owns_client: bool - _decoder: ResponseDecoder + _decoders: tuple[ResponseDecoder, ...] _user_middleware: tuple[AsyncMiddleware, ...] _dispatch: AsyncNext @@ -108,7 +108,7 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call limits: httpx2.Limits | None = None, auth: httpx2.Auth | None = None, httpx2_client: httpx2.AsyncClient | None = None, - decoder: ResponseDecoder | None = None, + decoders: Sequence[ResponseDecoder] | None = None, middleware: Sequence[AsyncMiddleware] = (), ) -> None: if httpx2_client is not None: @@ -144,10 +144,17 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call self._httpx2_client = httpx2.AsyncClient(**kwargs) self._owns_client = True - self._decoder = decoder if decoder is not None else _default_pydantic_decoder() + self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() self._user_middleware = tuple(middleware) self._dispatch = compose_async(self._user_middleware, self._terminal) + def _dispatch_decoder(self, model: type) -> ResponseDecoder | None: + """Walk `_decoders` and return the first decoder claiming `model`, or None.""" + for decoder in self._decoders: + if decoder.can_decode(model): + return decoder + return None + async def _terminal(self, request: httpx2.Request) -> httpx2.Response: try: async with _httpx2_exception_mapper(): @@ -172,11 +179,19 @@ async def send( response_model: type[T] | None = None, ) -> httpx2.Response | T: """Send `request` through the middleware chain. Decode if `response_model` is set.""" - response = await self._dispatch(request) if response_model is None: - return response + return await self._dispatch(request) + + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + + response = await self._dispatch(request) try: - return self._decoder.decode(response.content, response_model) + return decoder.decode(response.content, response_model) except Exception as exc: raise DecodeError(response=response, model=response_model, original=exc) from exc @@ -195,9 +210,16 @@ async def send_with_response( Not for streaming responses — decodes ``response.content``, which requires the body to be fully read. Use ``stream()`` for streaming. """ + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + response = await self._dispatch(request) try: - decoded = self._decoder.decode(response.content, response_model) + decoded = decoder.decode(response.content, response_model) except Exception as exc: raise DecodeError(response=response, model=response_model, original=exc) from exc return response, decoded diff --git a/tests/test_client_construction.py b/tests/test_client_construction.py index 2f9776f..a0312e3 100644 --- a/tests/test_client_construction.py +++ b/tests/test_client_construction.py @@ -5,7 +5,7 @@ import httpx2 import pytest -from httpware import AsyncClient +from httpware import AsyncClient, MissingDecoderError from httpware.client import _build_default_decoders from httpware.decoders.msgspec import MsgspecDecoder from httpware.decoders.pydantic import PydanticDecoder @@ -52,12 +52,12 @@ def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> AsyncClient(httpx2_client=caller, **kwargs) -def test_default_decoder_is_pydantic_decoder() -> None: +def test_default_decoders_includes_pydantic_when_installed() -> None: client = AsyncClient() - assert isinstance(client._decoder, PydanticDecoder) # noqa: SLF001 + assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 -def test_explicit_decoder_is_honored() -> None: +def test_explicit_decoders_is_honored() -> None: class _Stub: def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover return True @@ -65,8 +65,35 @@ def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover return None - client = AsyncClient(decoder=_Stub()) - assert isinstance(client._decoder, _Stub) # noqa: SLF001 + stub = _Stub() + client = AsyncClient(decoders=[stub]) + assert client._decoders == (stub,) # noqa: SLF001 + + +def test_empty_decoders_is_honored() -> None: + client = AsyncClient(decoders=[]) + assert client._decoders == () # noqa: SLF001 + + +async def test_missing_decoder_raised_before_http_call() -> None: + """response_model with no claiming decoder raises before the transport is invoked.""" + + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + with pytest.raises(MissingDecoderError) as exc_info: + await client.get("https://example.test/x", response_model=_Foo) + assert exc_info.value.model is _Foo + assert exc_info.value.registered_names == () @pytest.mark.parametrize( diff --git a/tests/test_client_send_with_response.py b/tests/test_client_send_with_response.py index 29c0a0e..803fe2c 100644 --- a/tests/test_client_send_with_response.py +++ b/tests/test_client_send_with_response.py @@ -6,7 +6,7 @@ import pydantic import pytest -from httpware import AsyncClient, ClientError, DecodeError, NotFoundError +from httpware import AsyncClient, ClientError, DecodeError, MissingDecoderError, NotFoundError from httpware.middleware import async_before_request @@ -128,3 +128,21 @@ def handler(request: httpx2.Request) -> httpx2.Response: response, _ = await client.send_with_response(request, response_model=_User) assert recorded[0].headers.get("x-test") == "ok" assert response.request.headers.get("x-test") == "ok" + + +async def test_send_with_response_raises_missing_decoder_before_http_call() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + request = client.build_request("GET", "https://example.test/x") + with pytest.raises(MissingDecoderError): + await client.send_with_response(request, response_model=_Foo) diff --git a/tests/test_decoders_msgspec.py b/tests/test_decoders_msgspec.py index 8b77a30..f2f2b7c 100644 --- a/tests/test_decoders_msgspec.py +++ b/tests/test_decoders_msgspec.py @@ -66,7 +66,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: transport = httpx2.MockTransport(handler) client = AsyncClient( httpx2_client=httpx2.AsyncClient(transport=transport), - decoder=MsgspecDecoder(), + decoders=[MsgspecDecoder()], ) with pytest.raises(DecodeError) as exc_info: await client.get("https://example.test/x", response_model=_Item) diff --git a/tests/test_optional_extras_pydantic_missing.py b/tests/test_optional_extras_pydantic_missing.py index 73234f6..5bbf3ae 100644 --- a/tests/test_optional_extras_pydantic_missing.py +++ b/tests/test_optional_extras_pydantic_missing.py @@ -32,12 +32,11 @@ def test_pydantic_decoder_init_raises_when_pydantic_missing() -> None: PydanticDecoder() -def test_async_client_default_decoder_raises_when_pydantic_missing() -> None: - with ( - patch("httpware._internal.import_checker.is_pydantic_installed", False), - pytest.raises(ImportError, match=r"httpware\[pydantic\]"), - ): - AsyncClient() +def test_async_client_no_pydantic_constructs_without_raising() -> None: + """AsyncClient() with pydantic missing must not raise — lazy default policy.""" + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = AsyncClient() + assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 def test_sync_client_default_decoder_raises_when_pydantic_missing() -> None: @@ -48,12 +47,12 @@ def test_sync_client_default_decoder_raises_when_pydantic_missing() -> None: Client() -def test_async_client_accepts_explicit_decoder_without_pydantic() -> None: - """An explicit decoder= escapes the fail-fast AND is actually wired to the client.""" +def test_async_client_accepts_explicit_decoders_without_pydantic() -> None: + """An explicit decoders= list is honored regardless of pydantic install state.""" fake = _FakeDecoder() with patch("httpware._internal.import_checker.is_pydantic_installed", False): - client = AsyncClient(decoder=fake) - assert client._decoder is fake # noqa: SLF001 — wired the explicit decoder, not a default + client = AsyncClient(decoders=[fake]) + assert client._decoders == (fake,) # noqa: SLF001 def test_sync_client_accepts_explicit_decoder_without_pydantic() -> None: From b0cdded17735d86d573a483d87ac8e4b332dabba Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 09:25:27 +0300 Subject: [PATCH 07/11] feat(client)!: sync Client takes decoders=[...] with type-dispatched routing --- src/httpware/client.py | 50 +++++++++++-------- src/httpware/decoders/pydantic.py | 12 ++--- tests/test_client_send_with_response_sync.py | 21 +++++++- tests/test_client_sync.py | 39 ++++++++++++--- .../test_optional_extras_pydantic_missing.py | 20 ++++---- 5 files changed, 97 insertions(+), 45 deletions(-) diff --git a/src/httpware/client.py b/src/httpware/client.py index 75ed912..30ed4f4 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -30,20 +30,6 @@ f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead." ) -_DEFAULT_DECODER_MISSING_MESSAGE = ( - "decoder=None defaults to PydanticDecoder, which requires the " - "'pydantic' extra. Either install it (`pip install httpware[pydantic]`) or " - "pass an explicit decoder=..." -) - - -def _default_pydantic_decoder() -> ResponseDecoder: - if not import_checker.is_pydantic_installed: - raise ImportError(_DEFAULT_DECODER_MISSING_MESSAGE) - from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design - - return PydanticDecoder() - def _build_default_decoders() -> tuple[ResponseDecoder, ...]: """Construct the default decoder tuple based on installed extras. @@ -833,7 +819,7 @@ class Client: _httpx2_client: httpx2.Client _owns_client: bool - _decoder: ResponseDecoder + _decoders: tuple[ResponseDecoder, ...] _user_middleware: tuple[Middleware, ...] _dispatch: Next @@ -848,7 +834,7 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call limits: httpx2.Limits | None = None, auth: httpx2.Auth | None = None, httpx2_client: httpx2.Client | None = None, - decoder: ResponseDecoder | None = None, + decoders: Sequence[ResponseDecoder] | None = None, middleware: Sequence[Middleware] = (), ) -> None: if httpx2_client is not None: @@ -884,10 +870,17 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call self._httpx2_client = httpx2.Client(**kwargs) self._owns_client = True - self._decoder = decoder if decoder is not None else _default_pydantic_decoder() + self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders() self._user_middleware = tuple(middleware) self._dispatch = compose(self._user_middleware, self._terminal) + def _dispatch_decoder(self, model: type) -> ResponseDecoder | None: + """Walk `_decoders` and return the first decoder claiming `model`, or None.""" + for decoder in self._decoders: + if decoder.can_decode(model): + return decoder + return None + def _terminal(self, request: httpx2.Request) -> httpx2.Response: try: with _httpx2_exception_mapper_sync(): @@ -936,11 +929,19 @@ def send( response_model: type[T] | None = None, ) -> httpx2.Response | T: """Send `request` through the middleware chain. Decode if `response_model` is set.""" - response = self._dispatch(request) if response_model is None: - return response + return self._dispatch(request) + + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + + response = self._dispatch(request) try: - return self._decoder.decode(response.content, response_model) + return decoder.decode(response.content, response_model) except Exception as exc: raise DecodeError(response=response, model=response_model, original=exc) from exc @@ -959,9 +960,16 @@ def send_with_response( Not for streaming responses — decodes ``response.content``, which requires the body to be fully read. Use ``stream()`` for streaming. """ + decoder = self._dispatch_decoder(response_model) + if decoder is None: + raise MissingDecoderError( + model=response_model, + registered_names=tuple(type(d).__name__ for d in self._decoders), + ) + response = self._dispatch(request) try: - decoded = self._decoder.decode(response.content, response_model) + decoded = decoder.decode(response.content, response_model) except Exception as exc: raise DecodeError(response=response, model=response_model, original=exc) from exc return response, decoded diff --git a/src/httpware/decoders/pydantic.py b/src/httpware/decoders/pydantic.py index 07fdbdc..9e26f96 100644 --- a/src/httpware/decoders/pydantic.py +++ b/src/httpware/decoders/pydantic.py @@ -1,12 +1,10 @@ """PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder. -Requires the `pydantic` extra: `pip install httpware[pydantic]`. The optional-extras -gate is enforced upstream — `client.py:_default_pydantic_decoder()` raises -ImportError when pydantic is absent, so this module is never imported in that -path. Tests simulating "pydantic not installed" patch -`import_checker.is_pydantic_installed=False` at runtime, after this module is -already loaded; `PydanticDecoder.__init__` then raises ImportError with the -install hint. +Requires the `pydantic` extra: `pip install httpware[pydantic]`. Constructing +`PydanticDecoder()` directly when pydantic is not installed raises ImportError. +The default-decoder path in `client.py:_build_default_decoders()` skips this +class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does +not trip the ImportError when the user is not using `response_model=`. """ import functools diff --git a/tests/test_client_send_with_response_sync.py b/tests/test_client_send_with_response_sync.py index d1c3b02..a5dd6a2 100644 --- a/tests/test_client_send_with_response_sync.py +++ b/tests/test_client_send_with_response_sync.py @@ -6,7 +6,7 @@ import pydantic import pytest -from httpware import Client, ClientError, DecodeError, NotFoundError +from httpware import Client, ClientError, DecodeError, MissingDecoderError, NotFoundError from httpware.middleware import before_request @@ -128,3 +128,22 @@ def handler(request: httpx2.Request) -> httpx2.Response: response, _ = client.send_with_response(request, response_model=_User) assert recorded[0].headers.get("x-test") == "ok" assert response.request.headers.get("x-test") == "ok" + + +def test_sync_send_with_response_raises_missing_decoder_before_http_call() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + request = client.build_request("GET", "https://example.test/x") + with pytest.raises(MissingDecoderError): + client.send_with_response(request, response_model=_Foo) + client.close() diff --git a/tests/test_client_sync.py b/tests/test_client_sync.py index 1a8c5c9..9ee9d85 100644 --- a/tests/test_client_sync.py +++ b/tests/test_client_sync.py @@ -8,7 +8,7 @@ from httpware import Client, NotFoundError from httpware.decoders.pydantic import PydanticDecoder -from httpware.errors import TransportError +from httpware.errors import MissingDecoderError, TransportError # ---------- Construction ---------- @@ -59,13 +59,13 @@ def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> caller.close() -def test_default_decoder_is_pydantic_decoder() -> None: +def test_default_decoders_includes_pydantic_when_installed() -> None: client = Client() - assert isinstance(client._decoder, PydanticDecoder) # noqa: SLF001 + assert any(isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 client.close() -def test_explicit_decoder_is_honored() -> None: +def test_explicit_decoders_is_honored() -> None: class _Stub: def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover return True @@ -73,8 +73,35 @@ def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover return None - client = Client(decoder=_Stub()) - assert isinstance(client._decoder, _Stub) # noqa: SLF001 + stub = _Stub() + client = Client(decoders=[stub]) + assert client._decoders == (stub,) # noqa: SLF001 + client.close() + + +def test_empty_decoders_is_honored() -> None: + client = Client(decoders=[]) + assert client._decoders == () # noqa: SLF001 + client.close() + + +def test_sync_missing_decoder_raised_before_http_call() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked when MissingDecoderError fires") + + transport = httpx2.MockTransport(handler) + client = Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=[], + ) + + class _Foo: + pass + + with pytest.raises(MissingDecoderError) as exc_info: + client.get("https://example.test/x", response_model=_Foo) + assert exc_info.value.model is _Foo + assert exc_info.value.registered_names == () client.close() diff --git a/tests/test_optional_extras_pydantic_missing.py b/tests/test_optional_extras_pydantic_missing.py index 5bbf3ae..8383611 100644 --- a/tests/test_optional_extras_pydantic_missing.py +++ b/tests/test_optional_extras_pydantic_missing.py @@ -39,12 +39,12 @@ def test_async_client_no_pydantic_constructs_without_raising() -> None: assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 -def test_sync_client_default_decoder_raises_when_pydantic_missing() -> None: - with ( - patch("httpware._internal.import_checker.is_pydantic_installed", False), - pytest.raises(ImportError, match=r"httpware\[pydantic\]"), - ): - Client() +def test_sync_client_no_pydantic_constructs_without_raising() -> None: + """Client() with pydantic missing must not raise — lazy default policy.""" + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = Client() + assert all(not isinstance(d, PydanticDecoder) for d in client._decoders) # noqa: SLF001 + client.close() def test_async_client_accepts_explicit_decoders_without_pydantic() -> None: @@ -55,9 +55,9 @@ def test_async_client_accepts_explicit_decoders_without_pydantic() -> None: assert client._decoders == (fake,) # noqa: SLF001 -def test_sync_client_accepts_explicit_decoder_without_pydantic() -> None: - """Sync mirror: explicit decoder= escapes the fail-fast AND is wired for sync Client too.""" +def test_sync_client_accepts_explicit_decoders_without_pydantic() -> None: fake = _FakeDecoder() with patch("httpware._internal.import_checker.is_pydantic_installed", False): - client = Client(decoder=fake) - assert client._decoder is fake # noqa: SLF001 — wired the explicit decoder, not a default + client = Client(decoders=[fake]) + assert client._decoders == (fake,) # noqa: SLF001 + client.close() From 89ccda5388b359bb4174877b6febe0808f800cc3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 09:31:19 +0300 Subject: [PATCH 08/11] test(client): cover default-decoder resolution matrix for both clients --- tests/test_client_decoders_default.py | 102 ++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/test_client_decoders_default.py diff --git a/tests/test_client_decoders_default.py b/tests/test_client_decoders_default.py new file mode 100644 index 0000000..71bf013 --- /dev/null +++ b/tests/test_client_decoders_default.py @@ -0,0 +1,102 @@ +"""Default decoder resolution under varying extras-installed states. + +Covers the behavior matrix in planning/specs/2026-06-09-multi-decoder-design.md +— `AsyncClient()` / `Client()` resolve `decoders=None` against the +`import_checker` flags at __init__ time. +""" + +from unittest.mock import patch + +from httpware import AsyncClient, Client +from httpware.decoders.msgspec import MsgspecDecoder +from httpware.decoders.pydantic import PydanticDecoder + + +def test_async_default_both_extras_installed() -> None: + client = AsyncClient() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder, MsgspecDecoder) + + +def test_async_default_pydantic_only() -> None: + with patch("httpware._internal.import_checker.is_msgspec_installed", False): + client = AsyncClient() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder,) + + +def test_async_default_msgspec_only() -> None: + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = AsyncClient() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (MsgspecDecoder,) + + +def test_async_default_neither_installed() -> None: + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + client = AsyncClient() + assert client._decoders == () # noqa: SLF001 + + +def test_async_empty_explicit_decoders() -> None: + client = AsyncClient(decoders=[]) + assert client._decoders == () # noqa: SLF001 + + +def test_async_explicit_decoders_skip_default_probe() -> None: + class _Custom: + def can_decode(self, model: type) -> bool: # noqa: ARG002 # pragma: no cover + return True + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover + return None + + custom = _Custom() + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + client = AsyncClient(decoders=[custom]) + assert client._decoders == (custom,) # noqa: SLF001 + + +def test_sync_default_both_extras_installed() -> None: + client = Client() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder, MsgspecDecoder) + client.close() + + +def test_sync_default_pydantic_only() -> None: + with patch("httpware._internal.import_checker.is_msgspec_installed", False): + client = Client() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (PydanticDecoder,) + client.close() + + +def test_sync_default_msgspec_only() -> None: + with patch("httpware._internal.import_checker.is_pydantic_installed", False): + client = Client() + types = tuple(type(d) for d in client._decoders) # noqa: SLF001 + assert types == (MsgspecDecoder,) + client.close() + + +def test_sync_default_neither_installed() -> None: + with ( + patch("httpware._internal.import_checker.is_pydantic_installed", False), + patch("httpware._internal.import_checker.is_msgspec_installed", False), + ): + client = Client() + assert client._decoders == () # noqa: SLF001 + client.close() + + +def test_sync_empty_explicit_decoders() -> None: + client = Client(decoders=[]) + assert client._decoders == () # noqa: SLF001 + client.close() From 485073df2adf8a2bd362fb78b385a8e3bd61970f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 09:33:30 +0300 Subject: [PATCH 09/11] test(client): cover type-dispatched decoder routing across both clients --- tests/test_client_dispatch.py | 209 ++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/test_client_dispatch.py diff --git a/tests/test_client_dispatch.py b/tests/test_client_dispatch.py new file mode 100644 index 0000000..398df6a --- /dev/null +++ b/tests/test_client_dispatch.py @@ -0,0 +1,209 @@ +"""Dispatch routing across multiple registered decoders. + +Covers the routing examples in planning/specs/2026-06-09-multi-decoder-design.md +§ Architecture — native types route via their library regardless of order, +shared shapes route to the first decoder in the list. +""" + +import dataclasses +from http import HTTPStatus + +import httpx2 +import msgspec +import pydantic +import pytest + +from httpware import AsyncClient, Client, MissingDecoderError +from httpware.decoders.msgspec import MsgspecDecoder +from httpware.decoders.pydantic import PydanticDecoder + + +class _PydanticUser(pydantic.BaseModel): + id: int + name: str + + +class _MsgspecUser(msgspec.Struct): + id: int + name: str + + +@dataclasses.dataclass +class _DC: + id: int + name: str + + +def _async_client_with_body(payload: bytes, decoders: list) -> AsyncClient: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, content=payload, request=request) + + transport = httpx2.MockTransport(handler) + return AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=decoders, + ) + + +def _sync_client_with_body(payload: bytes, decoders: list) -> Client: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, content=payload, request=request) + + transport = httpx2.MockTransport(handler) + return Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=decoders, + ) + + +async def test_async_basemodel_routes_to_pydantic() -> None: + client = _async_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=_PydanticUser) + assert type(result) is _PydanticUser + assert result.id == 1 + + +async def test_async_struct_routes_to_msgspec() -> None: + client = _async_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=_MsgspecUser) + assert type(result) is _MsgspecUser + assert result.id == 1 + + +async def test_async_dict_routes_to_first_decoder() -> None: + """Shared shape: first decoder in the list wins.""" + pyd = PydanticDecoder() + msg = MsgspecDecoder() + client = _async_client_with_body(b'{"a": 1}', decoders=[pyd, msg]) + result = await client.get("https://example.test/x", response_model=dict[str, int]) + assert type(result) is dict + assert result == {"a": 1} + + +async def test_async_dict_routes_to_msgspec_when_first() -> None: + """Reversed list flips routing for shared shapes.""" + client = _async_client_with_body( + b'{"a": 1}', + decoders=[MsgspecDecoder(), PydanticDecoder()], + ) + result = await client.get("https://example.test/x", response_model=dict[str, int]) + assert result == {"a": 1} + + +async def test_async_dataclass_routes_to_first_decoder() -> None: + client = _async_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=_DC) + assert type(result) is _DC + assert result.id == 1 + + +async def test_async_list_of_basemodel_routes_to_pydantic() -> None: + client = _async_client_with_body( + b'[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}]', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = await client.get("https://example.test/x", response_model=list[_PydanticUser]) + assert len(result) == 2 # noqa: PLR2004 + assert all(type(item) is _PydanticUser for item in result) + + +async def test_async_missing_decoder_with_empty_list() -> None: + """Empty decoder list and response_model= raises before HTTP call.""" + + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[], + ) + with pytest.raises(MissingDecoderError) as exc_info: + await client.get("https://example.test/x", response_model=_PydanticUser) + assert exc_info.value.registered_names == () + + +async def test_async_missing_decoder_when_none_claim() -> None: + """Registered decoders that all reject the model raise MissingDecoderError.""" + + class _Stub: + def can_decode(self, model: type) -> bool: # noqa: ARG002 + return False + + def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover + return None + + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked") + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoders=[_Stub()], + ) + with pytest.raises(MissingDecoderError) as exc_info: + await client.get("https://example.test/x", response_model=_PydanticUser) + assert exc_info.value.registered_names == ("_Stub",) + + +def test_sync_basemodel_routes_to_pydantic() -> None: + client = _sync_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = client.get("https://example.test/x", response_model=_PydanticUser) + assert type(result) is _PydanticUser + client.close() + + +def test_sync_struct_routes_to_msgspec() -> None: + client = _sync_client_with_body( + b'{"id": 1, "name": "Ada"}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = client.get("https://example.test/x", response_model=_MsgspecUser) + assert type(result) is _MsgspecUser + client.close() + + +def test_sync_dict_routes_to_first_decoder() -> None: + client = _sync_client_with_body( + b'{"a": 1}', + decoders=[PydanticDecoder(), MsgspecDecoder()], + ) + result = client.get("https://example.test/x", response_model=dict[str, int]) + assert result == {"a": 1} + client.close() + + +def test_sync_dict_routes_to_msgspec_when_first() -> None: + client = _sync_client_with_body( + b'{"a": 1}', + decoders=[MsgspecDecoder(), PydanticDecoder()], + ) + result = client.get("https://example.test/x", response_model=dict[str, int]) + assert result == {"a": 1} + client.close() + + +def test_sync_missing_decoder_with_empty_list() -> None: + def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover + pytest.fail("transport should not be invoked") + + transport = httpx2.MockTransport(handler) + client = Client( + httpx2_client=httpx2.Client(transport=transport), + decoders=[], + ) + with pytest.raises(MissingDecoderError): + client.get("https://example.test/x", response_model=_PydanticUser) + client.close() From 56f27fb41b8efd6519b5196fc970059a591fd888 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 09:38:02 +0300 Subject: [PATCH 10/11] docs: rewrite decoder narrative for multi-decoder routing --- README.md | 7 ++++--- docs/errors.md | 17 ++++++++++++++++- docs/index.md | 32 +++++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cf46d85..a131a28 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ ```bash pip install httpware # core only — no decoder -pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path) -pip install httpware[msgspec] # + MsgspecDecoder +pip install httpware[pydantic] # + PydanticDecoder — handles BaseModel + dataclasses + primitives + generics +pip install httpware[msgspec] # + MsgspecDecoder — handles Struct + dataclasses + primitives + generics +pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec pip install httpware[all] # everything declared above (pydantic, msgspec, otel) ``` -`AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing. +`AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. `AsyncClient()` never raises on missing extras — failure is deferred to the first `response_model=` call, where `MissingDecoderError` fires *before* the HTTP request if no registered decoder claims the model. ## Quickstart diff --git a/docs/errors.md b/docs/errors.md index 9c2452b..00729a6 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -27,7 +27,8 @@ ClientError (catch-all for anything httpware raises) │ └── ServiceUnavailableError (503) ├── RetryBudgetExhaustedError (a retry was needed but the budget refused) ├── BulkheadFullError (acquire_timeout elapsed before a slot opened) -└── DecodeError (response_model= decoder failed; HTTP call itself succeeded) +├── DecodeError (response_model= decoder failed; HTTP call itself succeeded) +└── MissingDecoderError (no registered decoder claims response_model=; fires before the HTTP call) ``` ## Status-to-exception mapping @@ -155,6 +156,20 @@ except DecodeError as exc: raise ``` +## `MissingDecoderError` + +Raised by `send()` / `send_with_response()` / verb methods when `response_model=` is set but no registered decoder claims the model. Carries: + +- `model: type` — the `response_model=` value that wasn't claimed. +- `registered_names: tuple[str, ...]` — class names of the registered decoders that all rejected the model. Empty tuple means no decoders were registered. + +Corrective action depends on the message hint: + +- `no decoders registered. Install pip install httpware[pydantic] or pip install httpware[msgspec], or pass decoders=[...] explicitly.` — install an extra or pass an explicit decoder list. +- `registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it.` — your `response_model` type is exotic enough that neither built-in claims it. Pass a custom `ResponseDecoder` via `decoders=[...]`. + +Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic is sent. + ## See also - **[Resilience reference](resilience.md)** — `AsyncRetry`, `RetryBudget`, `AsyncBulkhead` parameter tables. diff --git a/docs/index.md b/docs/index.md index 42df14c..952ae79 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,8 +13,9 @@ pip install httpware Optional extras: ```bash -pip install httpware[pydantic] # PydanticDecoder (the default decoder path) -pip install httpware[msgspec] # MsgspecDecoder +pip install httpware[pydantic] # PydanticDecoder — handles BaseModel + dataclasses + primitives + generics +pip install httpware[msgspec] # MsgspecDecoder — handles Struct + dataclasses + primitives + generics +pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec ``` ## First request @@ -64,6 +65,31 @@ async def main() -> None: Need the raw response **and** a decoded body from the same call (e.g., for header-based pagination)? See [Link header pagination](recipes/link-header-pagination.md) — it uses `send_with_response`. +### Decoder dispatch + +When `response_model=` is set, the client walks `decoders` in order and picks +the first decoder whose `can_decode(model)` returns `True`. Both built-in +decoders claim broadly within their library; the ordering encodes your +preference for shared shapes (`dict`, `list[Foo]`, dataclasses, primitives): + +```python +# pydantic-first (the default when both extras are installed): +# - BaseModel -> pydantic +# - Struct -> msgspec +# - dict, list -> pydantic (first in list) +AsyncClient(decoders=[PydanticDecoder(), MsgspecDecoder()]) + +# msgspec-first — same native routing, but shared shapes go to msgspec: +# - BaseModel -> pydantic +# - Struct -> msgspec +# - dict, list -> msgspec +AsyncClient(decoders=[MsgspecDecoder(), PydanticDecoder()]) +``` + +If no registered decoder claims your `response_model`, the call raises +`MissingDecoderError` *before* the HTTP request — see the +[Errors reference](errors.md#missingdecodererror). + ### With resilience middleware Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts. @@ -109,7 +135,7 @@ All errors inherit `httpware.ClientError`. The categories: - **Status errors** (4xx/5xx responses) — raised automatically, no `raise_for_status()` needed: `NotFoundError`, `RateLimitedError`, `ServiceUnavailableError`, and the rest. All subclass `StatusError`. - **Transport errors** — connection / network / protocol failures before a response arrived. `NetworkError` (transient) subclasses `TransportError`. - **Resilience refusals** — `RetryBudgetExhaustedError` and `BulkheadFullError`, raised by the resilience middleware. -- **Decode errors** — `DecodeError`, raised when `response_model=` decoding fails (HTTP call itself succeeded). +- **Decode errors** — `DecodeError`, raised when `response_model=` decoding fails (HTTP call itself succeeded). `MissingDecoderError`, raised when no registered decoder claims the `response_model=` type — fires *before* the HTTP call. See the [Errors reference](errors.md) for the full tree and catching strategies. From 100e9eebefc9d122c5642db80ea73c1c47685716 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 10 Jun 2026 09:40:55 +0300 Subject: [PATCH 11/11] docs(planning): update Seam B for multi-decoder routing --- planning/engineering.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/planning/engineering.md b/planning/engineering.md index 21cb66c..6f6cd34 100644 --- a/planning/engineering.md +++ b/planning/engineering.md @@ -4,7 +4,7 @@ This doc is the single distilled reference for `httpware` design rationale, prot ## 1. Project intent -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. `AsyncClient(decoder=None)` defaults to constructing a `PydanticDecoder` and so requires the `pydantic` extra; callers can supply an explicit `decoder=` argument to escape the default. As of 0.4.0, the package ships a small resilience suite under `httpware.middleware.resilience` — a `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — composed via the standard middleware chain. As of 0.5.0, `AsyncClient.stream()` provides a context-manager API for chunked response bodies; it bypasses the middleware chain by design (see planning/archive/specs/2026-06-05-streaming-design.md). As of 0.6.0, `Retry` and `Bulkhead` emit operational events via stdlib `logging` records (`httpware.retry` / `httpware.bulkhead` loggers) and — when `opentelemetry-api` is installed — OpenTelemetry span events on the active span. As of 0.7.0, the first-cut user-docs surface is live at (Middleware, Resilience, Errors, Testing guides) and Epic 3 is closed. +`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. As of 0.9.0, both clients take `decoders: Sequence[ResponseDecoder] | None = None` (a *list*, not a single instance) and dispatch via each decoder's `can_decode(model)` predicate; the default resolves against installed extras (pydantic-first when both present) and `AsyncClient()` / `Client()` no longer raise on missing extras. A new `MissingDecoderError` (sibling of `DecodeError` under `ClientError`) fires before the HTTP call when `response_model=` is set but no registered decoder claims the model. As of 0.4.0, the package ships a small resilience suite under `httpware.middleware.resilience` — a `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — composed via the standard middleware chain. As of 0.5.0, `AsyncClient.stream()` provides a context-manager API for chunked response bodies; it bypasses the middleware chain by design (see planning/archive/specs/2026-06-05-streaming-design.md). As of 0.6.0, `Retry` and `Bulkhead` emit operational events via stdlib `logging` records (`httpware.retry` / `httpware.bulkhead` loggers) and — when `opentelemetry-api` is installed — OpenTelemetry span events on the active span. As of 0.7.0, the first-cut user-docs surface is live at (Middleware, Resilience, Errors, Testing guides) and Epic 3 is closed. As of 0.8.0 the async middleware surface uses the `Async*`/`async_*` prefix (aligning with httpx2's convention); the `attempt_timeout=` kwarg was removed from `AsyncRetry` in the same release — see `planning/specs/2026-06-07-sync-client-design.md` for the rationale. @@ -36,11 +36,15 @@ The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) h - **Contract:** the middleware chain is composed once at client construction and frozen for the client's lifetime. Both worlds follow the same contract; the only difference is the per-world type: `AsyncClient` composes `AsyncMiddleware` via `compose_async` (the continuation type is `AsyncNext`), and `Client` composes `Middleware` via `compose` (the continuation type is `Next`). Both `compose` and `compose_async` live in `src/httpware/middleware/chain.py`. The chain bottom (the "terminal") is internal: it calls `self._httpx2_client.send(request)`, maps `httpx2` errors to `httpware` errors, and raises a `StatusError` subclass on 4xx/5xx. Same lifecycle rules in both worlds. - **Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites. -### Seam B: `Client`/`AsyncClient` ↔ `ResponseDecoder` +### Seam B: `Client`/`AsyncClient` ↔ `ResponseDecoder` list - **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. -- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Any exception raised by `decode` is wrapped by the call sites in `client.py` — `Client.send` / `AsyncClient.send` (when `response_model=` is set) and `Client.send_with_response` / `AsyncClient.send_with_response` — into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly. -- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` itself memoized via `@functools.lru_cache(maxsize=1024)` on a module-level `_get_adapter(model)` factory (the adapter is the expensive part to build). The msgspec adapter implements it as `msgspec.json.decode(content, type=model)`. +- **Contract:** the client holds `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime. The Protocol exposes two methods: + - `can_decode(model: type) -> bool` — predicate used at send-time to walk `_decoders` and pick the first claiming decoder (`_dispatch_decoder` on both classes). Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.inspect.type_info(model)` + `CustomType` filter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. + - `decode(content: bytes, model: type[T]) -> T` — the decode itself. Any exception is wrapped by `Client.send` / `AsyncClient.send` (when `response_model=` is set) and `Client.send_with_response` / `AsyncClient.send_with_response` into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly. +- **Pre-flight check:** when `response_model=` is set and no decoder claims it, `send` / `send_with_response` raise `MissingDecoderError(model=..., registered_names=...)` BEFORE the HTTP call. Distinct from `DecodeError` (which means the decoder ran and the payload was malformed); distinct corrective actions (install an extra or pass `decoders=[...]`). +- **Default list:** `decoders=None` resolves via `client.py:_build_default_decoders()` against installed extras — pydantic-first when both are present, either-only when only one is installed, empty tuple when neither. `AsyncClient()` / `Client()` never raise on missing extras; failure surfaces only at the first `response_model=` use site. +- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` itself memoized via `@functools.lru_cache(maxsize=1024)` on a module-level `_get_adapter(model)` factory; the msgspec adapter mirrors the pattern with a cached `msgspec.json.Decoder(model)`. ### Seam C: `httpware ↔ optional extras` @@ -118,7 +122,7 @@ Each extra's code lives in a single dedicated module (`decoders/pydantic.py`, `d New extras are added at the same time as the code that uses them — never preemptively. (An `otel` extra existed pre-0.4 but was removed once we noticed it was advertising functionality that didn't exist. 0.6.0 reintroduces it paired with the code that uses it — `Retry` and `Bulkhead` add events to the active OpenTelemetry span via `trace.get_current_span().add_event(...)`.) -Caller-facing pattern: consumers select the implementation by passing it explicitly, e.g., `AsyncClient(decoder=PydanticDecoder())`. There is no auto-detection or implicit registry. +Caller-facing pattern: as of 0.9.0, `AsyncClient()` / `Client()` default `decoders=None` resolves via `_build_default_decoders()` against installed extras (pydantic-first when both are present; empty tuple when neither). Consumers override by passing `decoders=[...]` explicitly; `decoders=[]` is honored as an opt-out. The auto-resolution is a snapshot of `import_checker.is__installed` flags at `__init__` time — there is no runtime re-detection or implicit registry beyond the two built-in decoders. ## 8. Remaining roadmap