diff --git a/planning/engineering.md b/planning/engineering.md index c008e4a..37a6680 100644 --- a/planning/engineering.md +++ b/planning/engineering.md @@ -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. diff --git a/src/httpware/decoders/__init__.py b/src/httpware/decoders/__init__.py index 296875e..7bc6983 100644 --- a/src/httpware/decoders/__init__.py +++ b/src/httpware/decoders/__init__.py @@ -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. """ ...