Skip to content

Commit b0b2bca

Browse files
committed
docs(release): 0.9.0 release notes
1 parent 1d2a0e5 commit b0b2bca

1 file changed

Lines changed: 95 additions & 0 deletions

File tree

planning/releases/0.9.0.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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

Comments
 (0)