httpware 0.9.0 — multi-decoder routing
Breaking release. Replaces the single-decoder slot on AsyncClient/Client with a type-dispatched decoders=[...] list. Reverses the 0.3.0 fail-fast for missing pydantic — AsyncClient() no longer raises on missing extras; failure is deferred to the first response_model= use site via the new MissingDecoderError (fires before the HTTP call).
If you currently pass decoder=PydanticDecoder() or rely on the old "pydantic must be installed for AsyncClient()" behavior, migration is one mechanical pass — see "Migration" below.
What's new
- Mixed pydantic + msgspec models in one client.
AsyncClient(decoders=[PydanticDecoder(), MsgspecDecoder()])is the new default when both extras are installed.BaseModelresponse models route to pydantic,Structto msgspec, and shared shapes (dict,list[Foo], dataclasses, primitives) route to the first decoder in the list. - Type-dispatched routing via
can_decode.ResponseDecoderProtocol gainscan_decode(model: type) -> bool. The client walksdecodersin order and picks the first claimer. Built-in decoders claim broadly within their library; native types of the other library are rejected (pydantic rejectsmsgspec.Struct; msgspec rejectspydantic.BaseModelviamsgspec.inspect.type_info+CustomTypefilter). MissingDecoderErrorunderClientError, exported fromhttpware. Carriesmodel: typeandregistered_names: tuple[str, ...]. Fires before the HTTP call whenresponse_model=is set but no registered decoder claims it — distinct corrective action fromDecodeError(decoder ran, payload bad).- Lazy default policy.
AsyncClient()/Client()no longer raiseImportErrorwhen pydantic is missing. The defaultdecoders=Noneresolves againstis_pydantic_installed/is_msgspec_installedat__init__time; if neither extra is installed, the default is()and the client works fine for all paths that don't useresponse_model=. - Per-instance decoder caches. Internal refactor:
TypeAdapterandmsgspec.json.Decodercaches now live on the decoder instance (_adapters/_msgspec_decodersdicts) rather than module-level@functools.lru_cache. Cache lifetime matches the decoder/client. No user-visible change.
Breaking changes
Renames
| Old | New |
|---|---|
AsyncClient(decoder=...) |
AsyncClient(decoders=[...]) |
Client(decoder=...) |
Client(decoders=[...]) |
The old decoder= kwarg raises TypeError: unexpected keyword argument 'decoder' at construction. The error is at construction time, so any 0.8.x → 0.9.0 upgrade trips it immediately rather than at first request.
ResponseDecoder Protocol
Custom ResponseDecoder implementations must add can_decode(model: type) -> bool. For a catch-all decoder, the trivial migration is def can_decode(self, model): return True. Decoders that should only claim specific model types should implement the predicate to return True only for those.
Behavioral reversal
AsyncClient() / Client() constructed without decoders= no longer raise ImportError when pydantic is missing. The 0.3.0 fail-fast (introduced when pydantic moved to an optional extra) is gone — failure now surfaces only when response_model= is used and no registered decoder claims it.
Users who relied on the eager ImportError for container-image validation should add an explicit smoke check, e.g.:
from httpware._internal import import_checker
assert import_checker.is_pydantic_installed, "pydantic extra missing"Removals
httpware.decoders.pydantic._get_adapterandhttpware.decoders.msgspec._get_msgspec_decodermodule-level functions — replaced with instance methods on the decoder classes. These were_-prefixed (private), so unless you were patching them in tests, no migration needed.httpware.client._default_pydantic_decoderand_DEFAULT_DECODER_MISSING_MESSAGE— both_-prefixed; no migration needed.
Migration
decoder= callers
# in your project root:
git ls-files '*.py' | xargs sed -i.bak \
-e 's/AsyncClient(decoder=/AsyncClient(decoders=[/g' \
-e 's/Client(decoder=/Client(decoders=[/g'Then walk the diff and close the brackets () → ])) wherever the kwarg was the only argument. For multi-argument calls, the regex catches the rename and you adjust the closing bracket by hand. Your type checker / first failing test will surface anything left.
Custom ResponseDecoder callers
If you have your own ResponseDecoder implementation, add can_decode. The trivial migration:
class MyDecoder:
def can_decode(self, model: type) -> bool:
return True # claim everything; existing behavior preserved
def decode(self, content: bytes, model: type) -> object:
...If your decoder is specialized to certain model types, gate can_decode accordingly so it doesn't claim models it can't actually handle — otherwise the dispatcher will route to your decoder and you'll raise at decode() time, wrapped as DecodeError. The clean shape is for can_decode to reject what you can't handle, letting another decoder in the list try.
Test-suite patches
If your tests patch httpware.decoders.pydantic._get_adapter or httpware.decoders.msgspec._get_msgspec_decoder (the module-level functions), retarget to the instance methods:
# was:
with patch("httpware.decoders.pydantic._get_adapter", side_effect=TypeError):
...
# now:
with patch.object(PydanticDecoder, "_get_adapter", side_effect=TypeError):
...References
- Design spec:
planning/specs/2026-06-09-multi-decoder-design.md - Implementation plan:
planning/plans/2026-06-09-multi-decoder-plan.md - Cache-refactor spec:
planning/specs/2026-06-10-decoder-instance-cache-design.md - Cache-refactor plan:
planning/plans/2026-06-10-decoder-instance-cache-plan.md - Engineering notes:
planning/engineering.md§3 Seam B - PRs: #41, #42