Skip to content

Commit faccba8

Browse files
lesnik512claude
andcommitted
docs(decoders): document the can_decode no-raise obligation
Closes the final delta-audit Nit (planning/audit/2026-06-12-delta-audit.md): the ResponseDecoder protocol was silent on the fact that can_decode runs in _dispatch_decoder, outside the DecodeError wrap — so a third-party decoder whose can_decode raises would escape the ClientError contract. Documents the obligation (return False to decline, never raise) on the ResponseDecoder.can_decode docstring and engineering.md Seam B, rather than adding an enforced guard: it is unreachable with the bundled decoders (both treat any probe failure as False), so the proportionate fix is the written contract, not defensive branches for a path nothing currently hits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5377bdd commit faccba8

2 files changed

Lines changed: 8 additions & 1 deletion

File tree

planning/engineering.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) h
4040

4141
- **Where:** `src/httpware/client.py``src/httpware/decoders/`.
4242
- **Contract:** the client holds `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime. The Protocol exposes two methods:
43-
- `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.
43+
- `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. `can_decode` MUST NOT raise — it runs in `_dispatch_decoder`, outside the `DecodeError` try/except, so a raising probe escapes the `ClientError` contract; a decoder that cannot decide must return False, not raise (the built-ins treat any probe failure as False). This is a documented obligation on implementers, not an enforced guard.
4444
- `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.
4545
- **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=[...]`).
4646
- **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.

src/httpware/decoders/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ def can_decode(self, model: type) -> bool:
1919
list ordering encodes the caller's preference for shared shapes.
2020
Native types of another library (e.g. `PydanticDecoder` vs
2121
`msgspec.Struct`) MUST be rejected.
22+
23+
`can_decode` MUST NOT raise. It runs at dispatch time — before the HTTP
24+
call and outside the `DecodeError` wrap that protects `decode` — so an
25+
exception here escapes the `ClientError` contract rather than being
26+
translated. A decoder that cannot determine support for `model` must
27+
return False (decline), not raise; the built-in decoders treat any
28+
probe failure as False.
2229
"""
2330
...
2431

0 commit comments

Comments
 (0)