|
| 1 | +# httpware 0.9.0 — multi-decoder routing |
| 2 | + |
| 3 | +**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). |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +## What's new |
| 8 | + |
| 9 | +- **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. |
| 10 | +- **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). |
| 11 | +- **`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). |
| 12 | +- **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=`. |
| 13 | +- **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. |
| 14 | + |
| 15 | +## Breaking changes |
| 16 | + |
| 17 | +### Renames |
| 18 | + |
| 19 | +| Old | New | |
| 20 | +|---|---| |
| 21 | +| `AsyncClient(decoder=...)` | `AsyncClient(decoders=[...])` | |
| 22 | +| `Client(decoder=...)` | `Client(decoders=[...])` | |
| 23 | + |
| 24 | +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. |
| 25 | + |
| 26 | +### `ResponseDecoder` Protocol |
| 27 | + |
| 28 | +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. |
| 29 | + |
| 30 | +### Behavioral reversal |
| 31 | + |
| 32 | +`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. |
| 33 | + |
| 34 | +Users who relied on the eager `ImportError` for container-image validation should add an explicit smoke check, e.g.: |
| 35 | + |
| 36 | +```python |
| 37 | +from httpware._internal import import_checker |
| 38 | +assert import_checker.is_pydantic_installed, "pydantic extra missing" |
| 39 | +``` |
| 40 | + |
| 41 | +### Removals |
| 42 | + |
| 43 | +- `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. |
| 44 | +- `httpware.client._default_pydantic_decoder` and `_DEFAULT_DECODER_MISSING_MESSAGE` — both `_`-prefixed; no migration needed. |
| 45 | + |
| 46 | +## Migration |
| 47 | + |
| 48 | +### `decoder=` callers |
| 49 | + |
| 50 | +```bash |
| 51 | +# in your project root: |
| 52 | +git ls-files '*.py' | xargs sed -i.bak \ |
| 53 | + -e 's/AsyncClient(decoder=/AsyncClient(decoders=[/g' \ |
| 54 | + -e 's/Client(decoder=/Client(decoders=[/g' |
| 55 | +``` |
| 56 | + |
| 57 | +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. |
| 58 | + |
| 59 | +### Custom `ResponseDecoder` callers |
| 60 | + |
| 61 | +If you have your own `ResponseDecoder` implementation, add `can_decode`. The trivial migration: |
| 62 | + |
| 63 | +```python |
| 64 | +class MyDecoder: |
| 65 | + def can_decode(self, model: type) -> bool: |
| 66 | + return True # claim everything; existing behavior preserved |
| 67 | + |
| 68 | + def decode(self, content: bytes, model: type) -> object: |
| 69 | + ... |
| 70 | +``` |
| 71 | + |
| 72 | +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. |
| 73 | + |
| 74 | +### Test-suite patches |
| 75 | + |
| 76 | +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: |
| 77 | + |
| 78 | +```python |
| 79 | +# was: |
| 80 | +with patch("httpware.decoders.pydantic._get_adapter", side_effect=TypeError): |
| 81 | + ... |
| 82 | + |
| 83 | +# now: |
| 84 | +with patch.object(PydanticDecoder, "_get_adapter", side_effect=TypeError): |
| 85 | + ... |
| 86 | +``` |
| 87 | + |
| 88 | +## References |
| 89 | + |
| 90 | +- Design spec: [`planning/specs/2026-06-09-multi-decoder-design.md`](../specs/2026-06-09-multi-decoder-design.md) |
| 91 | +- Implementation plan: [`planning/plans/2026-06-09-multi-decoder-plan.md`](../plans/2026-06-09-multi-decoder-plan.md) |
| 92 | +- Cache-refactor spec: [`planning/specs/2026-06-10-decoder-instance-cache-design.md`](../specs/2026-06-10-decoder-instance-cache-design.md) |
| 93 | +- Cache-refactor plan: [`planning/plans/2026-06-10-decoder-instance-cache-plan.md`](../plans/2026-06-10-decoder-instance-cache-plan.md) |
| 94 | +- Engineering notes: [`planning/engineering.md`](../engineering.md) §3 Seam B |
| 95 | +- PRs: [#41](https://github.com/modern-python/httpware/pull/41), [#42](https://github.com/modern-python/httpware/pull/42) |
0 commit comments