Skip to content

0.9.0 — multi-decoder routing

Latest

Choose a tag to compare

@lesnik512 lesnik512 released this 10 Jun 08:49
· 1 commit to main since this release
b0b2bca

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. BaseModel response models route to pydantic, Struct to msgspec, and shared shapes (dict, list[Foo], dataclasses, primitives) route to the first decoder in the list.
  • Type-dispatched routing via can_decode. ResponseDecoder Protocol gains can_decode(model: type) -> bool. The client walks decoders in order and picks the first claimer. Built-in decoders claim broadly within their library; native types of the other library are rejected (pydantic rejects msgspec.Struct; msgspec rejects pydantic.BaseModel via msgspec.inspect.type_info + CustomType filter).
  • MissingDecoderError under ClientError, exported from httpware. Carries model: type and registered_names: tuple[str, ...]. Fires before the HTTP call when response_model= is set but no registered decoder claims it — distinct corrective action from DecodeError (decoder ran, payload bad).
  • Lazy default policy. AsyncClient() / Client() no longer raise ImportError when pydantic is missing. The default decoders=None resolves against is_pydantic_installed / is_msgspec_installed at __init__ time; if neither extra is installed, the default is () and the client works fine for all paths that don't use response_model=.
  • Per-instance decoder caches. Internal refactor: TypeAdapter and msgspec.json.Decoder caches now live on the decoder instance (_adapters / _msgspec_decoders dicts) 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_adapter and httpware.decoders.msgspec._get_msgspec_decoder module-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_decoder and _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