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.
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
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.
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."
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/client.py b/src/httpware/client.py
index d341609..30ed4f4 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
@@ -30,19 +30,26 @@
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 _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)
-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
+ decoders.append(PydanticDecoder())
+ if import_checker.is_msgspec_installed:
+ from httpware.decoders.msgspec import MsgspecDecoder # noqa: PLC0415 — lazy by design (Seam C)
- return PydanticDecoder()
+ decoders.append(MsgspecDecoder())
+ return tuple(decoders)
@contextlib.asynccontextmanager
@@ -72,7 +79,7 @@ class AsyncClient:
_httpx2_client: httpx2.AsyncClient
_owns_client: bool
- _decoder: ResponseDecoder
+ _decoders: tuple[ResponseDecoder, ...]
_user_middleware: tuple[AsyncMiddleware, ...]
_dispatch: AsyncNext
@@ -87,7 +94,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:
@@ -123,10 +130,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():
@@ -151,11 +165,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
@@ -174,9 +196,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
@@ -790,7 +819,7 @@ class Client:
_httpx2_client: httpx2.Client
_owns_client: bool
- _decoder: ResponseDecoder
+ _decoders: tuple[ResponseDecoder, ...]
_user_middleware: tuple[Middleware, ...]
_dispatch: Next
@@ -805,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:
@@ -841,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():
@@ -893,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
@@ -916,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/__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..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
@@ -36,6 +34,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/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_client_construction.py b/tests/test_client_construction.py
index b580746..a0312e3 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 import AsyncClient, MissingDecoderError
+from httpware.client import _build_default_decoders
+from httpware.decoders.msgspec import MsgspecDecoder
from httpware.decoders.pydantic import PydanticDecoder
@@ -48,18 +52,48 @@ 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
+
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(
@@ -87,3 +121,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)
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()
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()
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_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 bcc3c13..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,19 +59,49 @@ 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
+
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_decoders_msgspec.py b/tests/test_decoders_msgspec.py
index c030902..f2f2b7c 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):
@@ -63,10 +66,95 @@ 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)
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_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_optional_extras_pydantic_missing.py b/tests/test_optional_extras_pydantic_missing.py
index a2bf733..8383611 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
@@ -29,33 +32,32 @@ 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:
- 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_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:
- """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()
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"