Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion planning/engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) h

- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`.
- **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.
- `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.
- `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.
Expand Down
7 changes: 7 additions & 0 deletions src/httpware/decoders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ def can_decode(self, model: type) -> bool:
list ordering encodes the caller's preference for shared shapes.
Native types of another library (e.g. `PydanticDecoder` vs
`msgspec.Struct`) MUST be rejected.

`can_decode` MUST NOT raise. It runs at dispatch time — before the HTTP
call and outside the `DecodeError` wrap that protects `decode` — so an
exception here escapes the `ClientError` contract rather than being
translated. A decoder that cannot determine support for `model` must
return False (decline), not raise; the built-in decoders treat any
probe failure as False.
"""
...

Expand Down