Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions architecture/decoders.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ A protocol seam is a documented internal boundary. AI agents and contributors mu
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. The Protocol exposes two methods:
- `can_decode(model: type) -> bool` — predicate used at send-time to walk `_decoders` and pick the first claiming decoder (`_dispatch_decoder` on both classes). Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.inspect.type_info(model)` + `CustomType` filter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. `can_decode` MUST NOT raise — it runs in `_dispatch_decoder`, outside the `DecodeError` try/except, so a raising probe escapes the `ClientError` contract; 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 `Client.send` / `AsyncClient.send` (when `response_model=` is set) and `Client.send_with_response` / `AsyncClient.send_with_response` into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly.
- **Pre-flight check:** when `response_model=` is set and no decoder claims it, `send` / `send_with_response` raise `MissingDecoderError(model=..., registered_names=...)` BEFORE the HTTP call. `MissingDecoderError` is a sibling of `DecodeError` under `ClientError`, and is distinct from it: `DecodeError` means the decoder ran and the payload was malformed; the two have distinct corrective actions (install an extra or pass `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 `_decoders` and pick the first claiming decoder (`_DecoderResolver.resolve`). Built-in decoders claim broadly (pydantic via `TypeAdapter(model)` probe, msgspec via `msgspec.inspect.type_info(model)` + `CustomType` filter); list ordering decides ambiguous shared shapes (dataclass, primitive, generic). Native types of another library MUST be rejected. `can_decode` MUST NOT raise — it runs in `_DecoderResolver.resolve`, outside the `_BoundDecoder.decode` wrap, so a raising probe escapes the `ClientError` contract; 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 decoder `resolve` returns, sealing decoder + model together — into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`); this runs for `send` / `send_with_response` on both clients when `response_model=` is set. Decoder implementers do not need to raise `DecodeError` directly.
- **Pre-flight check:** when `response_model=` is set and no decoder claims it, `_DecoderResolver.resolve` (called by `send` / `send_with_response` before `_dispatch`) raises `MissingDecoderError(model=..., registered_names=...)` BEFORE the HTTP call. `MissingDecoderError` is a sibling of `DecodeError` under `ClientError`, and is distinct from it: `DecodeError` means the decoder ran and the payload was malformed; the two have distinct corrective actions (install an extra or pass `decoders=[...]`).
- **Default list:** `decoders=None` resolves via `client.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 first `response_model=` use site.
- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` cached per-instance on `PydanticDecoder._adapters: dict[type, TypeAdapter]` (populated lazily on first `_get_adapter()` call); the msgspec adapter mirrors the pattern with `MsgspecDecoder._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.
144 changes: 144 additions & 0 deletions planning/changes/2026-06-23.02-decoder-resolver-extraction/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---
status: shipped
date: 2026-06-23
slug: decoder-resolver-extraction
summary: Extract a _DecoderResolver (+ generic _BoundDecoder) for Seam B, collapsing the 4-site resolve/raise/decode/wrap smear in client.py.
supersedes: null
superseded_by: null
pr: 77
outcome: Shipped via #77 — resolution + pre-flight MissingDecoderError + DecodeError wrapping moved into _DecoderResolver/_BoundDecoder (decoders/_resolver.py); the 4 send/send_with_response sites collapse to resolve→dispatch→bound.decode, both _dispatch_decoder methods removed, decoder+model sealed together. Behaviour byte-identical (723 tests, 100% coverage). New seam suite tests/test_decoder_resolver.py; promoted into architecture/decoders.md. Internal refactor — no release.
---

# Design: Extract a `_DecoderResolver` for Seam B

## Summary

The decoder seam (Seam B) is real — two adapters, pydantic and msgspec — but the
logic for *using* it is copy-pasted across four `send` / `send_with_response`
methods on the two clients. This change pulls that logic behind one
`_DecoderResolver` (plus a small generic `_BoundDecoder`) in a new
`decoders/_resolver.py`, so the resolution + pre-flight error + decode-wrapping
live once and the four call sites shrink to `resolve → dispatch → decode`.
Behaviour is unchanged.

## Motivation

- The same ~8-line block appears at `client.py:185`, `:213`, `:1200`, `:1228`:
walk `_decoders`, raise `MissingDecoderError(registered_names=…)` if none
claims the model (pre-flight, before the HTTP call), then `decoder.decode(...)`
wrapping any failure as `DecodeError`. Plus `_dispatch_decoder` defined twice.
- **Depth:** the `ResponseDecoder` protocol is a clean interface, but its *use*
is shallow — smeared across four call sites instead of behind one module.
Extracting concentrates it: one place to change the error wording or
resolution, and a seam testable directly with a fake decoder list (no HTTP).
- **Deletion test:** delete the resolver and the 8-line block reappears in all
four methods — it concentrates real complexity, so it earns its keep.
(Contrast `_dispatch_decoder` *today*: a 3-line loop, shallow on its own; the
win is bundling resolution **with** the pre-flight error and decode-wrapping.)
- Fully synchronous (`can_decode`/`decode` are sync), so **one** resolver shape
serves both `Client` and `AsyncClient` — no sync/async twins.

## Non-goals

- No behaviour change. Resolution order, the pre-flight `MissingDecoderError`
(with its `registered_names` snapshot), and `DecodeError` wrapping stay
byte-identical.
- Not changing the `ResponseDecoder` protocol, the pydantic/msgspec adapters, or
`_build_default_decoders()` (stays in `client.py`, still tested there).
- Not touching the `decoders=None → defaults` resolution — the resolver takes
the already-built tuple.

## Design

### 1. `decoders/_resolver.py` — `_DecoderResolver` + `_BoundDecoder`

```python
class _BoundDecoder(Generic[T]):
"""A decoder bound to the model it will decode into."""

def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None:
self._decoder = decoder
self._model = model

def decode(self, response: httpx2.Response) -> T:
try:
return self._decoder.decode(response.content, self._model)
except Exception as exc:
raise DecodeError(response=response, model=self._model, original=exc) from exc


class _DecoderResolver:
"""Resolves a response_model to the first claiming decoder; the Seam B orchestrator."""

def __init__(self, decoders: tuple[ResponseDecoder, ...]) -> None:
self._decoders = decoders

def resolve(self, model: type[T]) -> _BoundDecoder[T]:
for decoder in self._decoders:
if decoder.can_decode(model):
return _BoundDecoder(decoder, model)
raise MissingDecoderError(
model=model,
registered_names=tuple(type(d).__name__ for d in self._decoders),
)
```

`_BoundDecoder` seals the decoder and the model together at resolve time, so a
decoder/model mismatch is unrepresentable downstream; the caller's remaining
knowledge shrinks to "decode this response". Lives in a new private module
inside the decoders subpackage — co-located with the protocol + adapters it
orchestrates, no import cycle (`decoders/` never imports `client`).

### 2. The four call sites collapse

```python
bound = self._decoder_resolver.resolve(response_model) # pre-flight; may raise MissingDecoderError
response = self._dispatch(request)
return bound.decode(response) # post-HTTP; wraps DecodeError
```

`send_with_response` is the same but returns `(response, bound.decode(response))`.
The pre-flight invariant — `MissingDecoderError` before the HTTP call — stays
enforced by call ordering (`resolve` before `_dispatch`), exactly as
`_dispatch_decoder` is called before `_dispatch` today.

### 3. What the clients hold

- Keep `self._decoders` (four test files assert `client._decoders == …` via
`# noqa: SLF001` — it stays a public-by-convention attribute).
- Add `self._decoder_resolver = _DecoderResolver(self._decoders)` in both
`__init__`s.
- Remove both `_dispatch_decoder` methods (used nowhere but the four sites).

## Out of scope

- Folding `_build_default_decoders()` into the resolver.
- Any change to streaming (`stream()` never decodes — no `response_model`).

## Testing

- **Parity net:** existing decoder/client suites stay green unchanged —
`test_decoders_pydantic.py`, `test_decoders_msgspec.py`,
`test_client_decoders_default.py`, `test_client_construction.py`,
`test_client_sync.py`, `test_optional_extras_pydantic_missing.py`.
- **New seam tests:** `tests/test_decoder_resolver.py` drives `resolve` /
`_BoundDecoder.decode` directly with a fake decoder list (no client, no
`MockTransport`): claiming model → bound that decodes; no claimer →
`MissingDecoderError` with the right `registered_names` and order; decode
success; decode raises → `DecodeError` carrying `response` / `model` /
`original`; first-match-wins ordering across two fakes.
- `just lint` and `just test` both clean (100% coverage gate).

## Risk

- **Behavioural drift** (low × medium): a reordering changes resolution
precedence or the `registered_names` content. *Mitigation:* extract under the
green decoder/client suites, which assert defaults, ordering, and error
identity; do not edit them in this change.
- **Pre-flight ordering regression** (low × medium): `MissingDecoderError` must
still fire before the HTTP call. *Mitigation:* the call sites keep `resolve`
strictly before `_dispatch`; existing tests assert no request is sent on a
missing decoder.
- **Typing** (low × low): `_BoundDecoder` must be `Generic[T]` so `T` flows to
`send`'s overloads. *Mitigation:* `ty check` in the gate; `send`'s overload
tests in `test_client_typing.py` cover the surface.
152 changes: 152 additions & 0 deletions planning/changes/2026-06-23.02-decoder-resolver-extraction/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
---
status: shipped
date: 2026-06-23
slug: decoder-resolver-extraction
spec: decoder-resolver-extraction
pr: 77
---

# decoder-resolver-extraction — implementation plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use
> superpowers:subagent-driven-development (recommended) or
> superpowers:executing-plans to implement this plan task-by-task. Steps
> use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Move the 4-site decoder resolve/raise/decode/wrap smear behind one
`_DecoderResolver` (+ generic `_BoundDecoder`), with no behaviour change.

**Spec:** [`design.md`](./design.md)

**Branch:** `refactor/decoder-resolver-extraction`

**Commit strategy:** Per-task commits.

---

### Task 1: Add `_DecoderResolver` and rewire both clients

**Files:**
- Create: `src/httpware/decoders/_resolver.py`
- Modify: `src/httpware/client.py`

Existing decoder/client suites are the parity net — do not edit them in this task.

- [ ] **Step 1: Create `decoders/_resolver.py`**

Add `_BoundDecoder(Generic[T])` (holds decoder + model; `decode(response) -> T`
wrapping `except Exception` as `DecodeError`) and `_DecoderResolver`
(`__init__(decoders: tuple[ResponseDecoder, ...])`, `resolve(model: type[T]) ->
_BoundDecoder[T]` walking `_decoders`, raising `MissingDecoderError(model=…,
registered_names=tuple(type(d).__name__ for d in self._decoders))` when none
claims). Import `T` from `httpware.decoders` (the protocol's TypeVar) or define
locally; import `DecodeError`/`MissingDecoderError` from `httpware.errors`,
`ResponseDecoder` from `httpware.decoders`.

- [ ] **Step 2: Wire the resolver into both clients**

In `AsyncClient.__init__` and `Client.__init__`, after `self._decoders = …`,
add `self._decoder_resolver = _DecoderResolver(self._decoders)`. Keep
`self._decoders`. Import `_DecoderResolver` from
`httpware.decoders._resolver`.

- [ ] **Step 3: Replace the four call sites**

In `AsyncClient.send`, `AsyncClient.send_with_response`, `Client.send`,
`Client.send_with_response`, replace each resolve/raise/decode/wrap block with
`bound = self._decoder_resolver.resolve(response_model)` (before
`self._dispatch(request)`), then `bound.decode(response)` after. Keep the
`send_with_response` `(response, decoded)` shape. Remove both
`_dispatch_decoder` methods. `MissingDecoderError`/`DecodeError` imports in
`client.py` may become unused — drop them if so.

- [ ] **Step 4: Verify parity**

```bash
uv run pytest tests/test_decoders_pydantic.py tests/test_decoders_msgspec.py \
tests/test_client_decoders_default.py tests/test_client_construction.py \
tests/test_client_sync.py tests/test_optional_extras_pydantic_missing.py \
tests/test_client_typing.py --no-cov -q
```
All green, unchanged. If any fail, the extraction drifted — fix the resolver,
not the tests.

- [ ] **Step 5: Commit**

```bash
git add src/httpware/decoders/_resolver.py src/httpware/client.py
git commit -m "refactor(decoders): extract _DecoderResolver for Seam B

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 2: Add seam-level resolver tests

**Files:**
- Create: `tests/test_decoder_resolver.py`

Drive `resolve` / `_BoundDecoder.decode` directly with a fake decoder list — no
client, no `MockTransport`.

- [ ] **Step 1: Write the matrix**

A `FakeDecoder` claiming a set of model types (annotate all args). Cover:
claiming model → bound whose `.decode(response)` returns the decoded value;
no claimer → `MissingDecoderError` with `registered_names` matching the fake
list and order; first-match-wins across two fakes; decode success on good
bytes; decoder raises → `DecodeError` carrying `response` / `model` /
`original`; empty decoder tuple → `MissingDecoderError` with `()`.

- [ ] **Step 2: Run**

```bash
uv run pytest tests/test_decoder_resolver.py --no-cov -q
```
All green.

- [ ] **Step 3: Commit**

```bash
git add tests/test_decoder_resolver.py
git commit -m "test(decoders): cover _DecoderResolver at the seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: Promote to architecture, lint, full suite

**Files:**
- Modify: `architecture/decoders.md`
- Modify: `planning/changes/2026-06-23.02-decoder-resolver-extraction/design.md` (frontmatter at ship)

- [ ] **Step 1: Promote the living truth**

In `architecture/decoders.md`, replace the `_dispatch_decoder` references and
the "`send`/`send_with_response` raise `MissingDecoderError`" framing with the
`_DecoderResolver` / `_BoundDecoder` description: resolution + pre-flight error
+ decode-wrapping live in the resolver; the clients call `resolve` before
dispatch and `bound.decode` after. Keep it prose, no frontmatter.

- [ ] **Step 2: Full gate**

```bash
just lint && just test
```
Both clean (100% coverage). Confirm the `httpx2._` and other review-only
invariants still hold in the diff.

- [ ] **Step 3: Ship frontmatter + commit**

Set `status: shipped`, `pr`, and `outcome` in `design.md` once the PR number
exists. Run `just index` to confirm the listing regenerates.

```bash
git add architecture/decoders.md planning/changes/2026-06-23.02-decoder-resolver-extraction/
git commit -m "docs(decoders): promote _DecoderResolver into architecture truth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
Loading