A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.
Both clients take decoders: Sequence[ResponseDecoder] | None = None (a list, not a single instance) and dispatch via each decoder's can_decode(model) predicate. AsyncClient() / Client() do not raise on missing extras.
- 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, plus a_decoder_resolver: _DecoderResolver(decoders/_resolver.py) wrapping that tuple — the synchronous Seam B orchestrator both clients share. The Protocol exposes two methods:can_decode(model: type) -> bool— predicate used at send-time to walk_decodersand pick the first claiming decoder (_DecoderResolver.resolve). Built-in decoders claim broadly (pydantic viaTypeAdapter(model)probe, msgspec viamsgspec.inspect.type_info(model)+CustomTypefilter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected.can_decodeMUST NOT raise — it runs in_DecoderResolver.resolve, outside the_BoundDecoder.decodewrap, so a raising probe escapes theClientErrorcontract; 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_BoundDecoder.decode— the bound decoderresolvereturns, sealing decoder + model together — intohttpware.DecodeError(aClientErrorsubclass carryingresponse,model,original); this runs forsend/send_with_responseon both clients whenresponse_model=is set. Decoder implementers do not need to raiseDecodeErrordirectly.
- Pre-flight check: when
response_model=is set and no decoder claims it,_DecoderResolver.resolve(called bysend/send_with_responsebefore_dispatch) raisesMissingDecoderError(model=..., registered_names=...)BEFORE the HTTP call.MissingDecoderErroris a sibling ofDecodeErrorunderClientError, and is distinct from it:DecodeErrormeans the decoder ran and the payload was malformed; the two have distinct corrective actions (install an extra or passdecoders=[...]). - Default list:
decoders=Noneresolves viaclient.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 firstresponse_model=use site. - Rule: the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (
json.loadsthenvalidate_python) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediatedictallocation and parses faster. The Pydantic adapter implements this asTypeAdapter(model).validate_json(content), with theTypeAdaptercached per-instance onPydanticDecoder._adapters: dict[type, TypeAdapter](populated lazily on first_get_adapter()call); the msgspec adapter mirrors the pattern withMsgspecDecoder._msgspec_decoders: dict[type, msgspec.json.Decoder]. Cache lifetime matches the decoder/client, not the process — no module-level state, no autouse cache-clear fixtures in tests. Both built-ins'_get_adapter/_get_msgspec_decoderandcan_decodeshare one memoizing helper (decoders/_caching.py:_get_or_build). - Unhashable models: a
modelthat isn't hashable (e.g.Annotated[X, unhashable_metadata]) bypasses the cache entirely — every call builds fresh, uncached.can_decode's verdict reflects true buildability regardless of hashability: an unhashable model that the underlying library can actually build a schema/decoder for returnsTrue, notFalse. Hashability is orthogonal to whether a model can be decoded.