From 2254ccd44612cc1092a6348fecd10a406d4127d8 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 7 Jun 2026 23:39:26 +0300 Subject: [PATCH 01/10] spec: DecodeError wraps decoder exceptions at seam 3 Closes the gap where pydantic.ValidationError / msgspec.ValidationError escaped `except httpware.ClientError` when `response_model=` was set. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-06-07-decoder-error-design.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 planning/specs/2026-06-07-decoder-error-design.md diff --git a/planning/specs/2026-06-07-decoder-error-design.md b/planning/specs/2026-06-07-decoder-error-design.md new file mode 100644 index 0000000..892a1b6 --- /dev/null +++ b/planning/specs/2026-06-07-decoder-error-design.md @@ -0,0 +1,260 @@ +# Spec: `DecodeError` — close the decoder-exception gap at Seam 3 + +**Date:** 2026-06-07 +**Topic slug:** `decoder-error` +**Status:** drafted, awaiting user review +**Target release:** `0.8.1` (patch — the leaked exceptions weren't a documented contract, so wrapping them is a defect fix, not a contract change) + +## Purpose + +`httpware`'s README and the `Client` / `AsyncClient` class docstrings advertise a single exception tree — `httpware.ClientError` and its subclasses — as the catch-all for HTTP-call failure. Today that promise has a hole: when `response_model=` is passed and the active `ResponseDecoder` fails (malformed JSON, schema mismatch, or anything else), the backing-library exception (`pydantic.ValidationError`, `msgspec.ValidationError`, `msgspec.DecodeError`) propagates out of `Client.send` / `AsyncClient.send` untranslated. `except httpware.ClientError` does not catch it. + +This forces every consumer into one of two bad postures: + +1. **Skip the decoder entirely** — call `client.send(client.build_request(...))` without `response_model=` and re-decode the raw `httpx2.Response` manually. The installed extra (`httpware[pydantic]` or `[msgspec]`) becomes dead weight; the seam's whole point — decoder swappability — never delivers. +2. **Import the decoder library at the call site** — `except (httpware.ClientError, pydantic.ValidationError)`. Now switching from `PydanticDecoder` to `MsgspecDecoder` is a multi-file rewrite, not a config change. + +The fix introduces a new exception class — `httpware.DecodeError` — and a single try/except at the **Seam 3** (`Client/AsyncClient ↔ ResponseDecoder`) boundary. The decoder protocol stays silent on exceptions; the wrapping happens at the seam, in one place per world. After this change, `except httpware.ClientError` catches every failure mode of `client.send(..., response_model=M)`, regardless of which decoder is active. + +## Non-goals + +- **No change to `PydanticDecoder` or `MsgspecDecoder`.** They continue to raise their backing-library exceptions; the seam translates. Implementers of third-party decoders are not required to import `httpware.DecodeError`. +- **No change to the `ResponseDecoder` protocol signature.** `decode(content: bytes, model: type[T]) -> T` is unchanged. The protocol docstring grows one sentence documenting what the seam does, but the structural contract is unchanged. +- **No streaming-decode support.** `stream()` / `astream()` paths do not accept `response_model=` today, and adding decode support to them is out of scope. Seam 3 covers only the two `send` methods. +- **No mapping table for decoder library exceptions.** No `pydantic.ValidationError → SchemaMismatchError`, no `msgspec.DecodeError → MalformedJSONError`. The single `DecodeError` is enough — the original library exception is exposed via `DecodeError.original` for consumers who want to introspect. +- **No feature flag, env-var toggle, or shim layer.** The wrap is unconditional. Consumers catching pydantic/msgspec exceptions directly downstream of `send(...)` must switch to `except httpware.DecodeError` (or the broader `except httpware.ClientError`). +- **No special-case for nested `DecodeError`.** If a third-party decoder somehow raises `httpware.DecodeError` directly, the seam wrapper will catch and re-wrap it. The chain depth grows by one; `__cause__` still points to the real root. Not worth a guard in v1. +- **No deprecation pass.** The previously-leaking exceptions weren't part of httpware's documented surface, so there is nothing to deprecate. + +## Architecture + +### The seam — what changes, what doesn't + +`AsyncClient.send` (`src/httpware/client.py:147`) and `Client.send` (`src/httpware/client.py:864`) are the only two call sites of `self._decoder.decode(...)`. Both lines today read: + +```python +return self._decoder.decode(response.content, response_model) +``` + +After this change, both wrap the call in a try/except and raise `DecodeError` from any caught `Exception`. The `_dispatch(request)` call stays *outside* the try — transport/status errors are already mapped to `ClientError` subclasses upstream (`_terminal` in `client.py:130` and `client.py:823`) and we do not want to re-wrap those as `DecodeError`. + +The `ResponseDecoder` protocol (`src/httpware/decoders/__init__.py`) is unchanged in signature. Its docstring grows one sentence documenting that exceptions are translated by the seam, so implementers know they do not need to raise `DecodeError` themselves. + +`PydanticDecoder` and `MsgspecDecoder` are unchanged. + +### The exception — placement and shape + +`DecodeError` is a direct child of `ClientError`, sibling of `TransportError` / `TimeoutError` / `StatusError` / `RetryBudgetExhaustedError` / `BulkheadFullError`. Caught by `except httpware.ClientError`. The tree becomes: + +```text +ClientError +├─ TransportError +│ └─ NetworkError +├─ TimeoutError +├─ StatusError +│ ├─ ClientStatusError → {BadRequest, Unauthorized, Forbidden, NotFound, +│ │ Conflict, UnprocessableEntity, RateLimited} +│ └─ ServerStatusError → {InternalServer, ServiceUnavailable} +├─ RetryBudgetExhaustedError +├─ BulkheadFullError +└─ DecodeError ← new +``` + +`DecodeError` is *not* a `StatusError` subclass: the semantic of `StatusError` is "server signaled error via 4xx/5xx," which is exactly the case `DecodeError` does *not* cover (the request succeeded, the body is wrong). It is also *not* under a new intermediate parent (`PayloadError` or similar) — YAGNI; one-member intermediates rarely grow members. + +### Init shape + +Keyword-only init with three fields, matching the precedent set by `RetryBudgetExhaustedError` (`errors.py:158`) and `BulkheadFullError` (`errors.py:196`): + +```python +def __init__( + self, + *, + response: httpx2.Response, + model: type, + original: BaseException, +) -> None: + ... +``` + +- `response` — the full `httpx2.Response` returned by `_dispatch`. Carries status code, headers, request URL — everything consumers need for logging or translation. The body has already been fully read by the time `send` reaches the decoder, so there is no streaming-resource concern. +- `model` — the type passed to `response_model=`. Stored for consumer introspection (`if exc.model is MyResponse: …`) and for the error message. +- `original` — the underlying exception caught from the decoder. Typed `BaseException` for type-honest chaining (`raise DecodeError(...) from inner` sets `__cause__`); in practice always an `Exception` subclass because the seam catches `Exception`. + +`__reduce__` is implemented for pickle parity with the rest of the tree, following the same module-level `_reconstruct_*` pattern used by `StatusError`, `RetryBudgetExhaustedError`, and `BulkheadFullError`. + +Message format: `f"failed to decode response into {model.__name__}: {original}"`. Includes the model name and the original repr — terse enough for log lines, informative enough that an operator can diagnose without expanding the traceback. + +### Why `except Exception`, not narrower + +The seam wrapper catches `Exception`, not a narrower base. Rationale: + +- `pydantic.ValidationError` inherits from `ValueError` but that is a CPython implementation detail. +- `msgspec.ValidationError` and `msgspec.DecodeError` inherit from `Exception` directly. +- A third-party decoder might raise anything — `RuntimeError`, a custom exception, a `LookupError` for a missing field. + +Catching `Exception` covers all of these and deliberately leaves `BaseException` subclasses alone — `KeyboardInterrupt`, `SystemExit`, and `asyncio.CancelledError` (which is `BaseException` in 3.11+ when raised from cancel scopes) propagate untouched. This matches the posture of `_httpx2_exception_mapper_sync` already in `client.py`. + +## Code change inventory + +### `src/httpware/errors.py` + +Add `_reconstruct_decode_error` (module-level, used by `__reduce__`) and `DecodeError`: + +```python +def _reconstruct_decode_error( + cls: "type[DecodeError]", + response: httpx2.Response, + model: type, + original: BaseException, +) -> "DecodeError": + return cls(response=response, model=model, original=original) + + +class DecodeError(ClientError): + """Raised when the active ResponseDecoder failed to decode response.content. + + The HTTP call itself succeeded — status was 2xx/3xx and the transport + delivered the body intact — but the body could not be parsed into the + requested response_model. Always chained from the underlying library + exception via `raise ... from exc`; that exception is also exposed as + `self.original` for structured handling. + """ + + response: httpx2.Response + model: type + original: BaseException + + def __init__( + self, + *, + response: httpx2.Response, + model: type, + original: BaseException, + ) -> None: + self.response = response + self.model = model + self.original = original + super().__init__(f"failed to decode response into {model.__name__}: {original}") + + def __reduce__(self) -> tuple[Any, ...]: + return ( + _reconstruct_decode_error, + (type(self), self.response, self.model, self.original), + ) +``` + +### `src/httpware/client.py` + +Both `send` methods change identically. Async (`client.py:147–157`): + +```python +async def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, +) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + response = await self._dispatch(request) + if response_model is None: + return response + try: + return self._decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc +``` + +Sync (`client.py:864–874`): identical body, with `response = self._dispatch(request)` (no `await`). + +Extend the existing `from httpware.errors import TransportError` line at `client.py:19` to also import `DecodeError`. + +### `src/httpware/__init__.py` + +Add `DecodeError` to the `from httpware.errors import (...)` block and to `__all__`. Slot next to the other `ClientError` children, matching the existing alphabetic-within-group convention. + +### `src/httpware/decoders/__init__.py` + +Append one sentence to `ResponseDecoder.decode`'s docstring: + +> "Any exception raised by `decode` is wrapped by `Client.send` / `AsyncClient.send` into `httpware.DecodeError`; implementers do not need to raise `DecodeError` directly." + +No other change. The protocol structure is identical. + +### Out of scope (decoder classes themselves) + +`src/httpware/decoders/pydantic.py` and `src/httpware/decoders/msgspec.py` are not modified. + +## Tests + +### `tests/test_errors.py` + +Three new cases for `DecodeError`: + +- **Construction & fields.** `DecodeError(response=r, model=MyModel, original=exc)` stores all three; `str(err)` includes `MyModel` and `repr(exc)`; `isinstance(err, ClientError)` is true. Negative coverage: passing positional args raises `TypeError` (kwargs-only). +- **Chaining.** When raised via `raise DecodeError(...) from inner`, `err.__cause__ is inner` and `err.original is inner` (the two channels carry the same reference but neither is dropped). +- **Pickle round-trip.** `pickle.loads(pickle.dumps(err))` reconstructs an equal-fielded `DecodeError`. Mirrors the existing `RetryBudgetExhaustedError` and `BulkheadFullError` pickle tests. + +### `tests/test_client_response_model.py` + +Existing file already exercises the `response_model=` path; extend it with seam-level decode-failure cases. Each case runs against both `Client` and `AsyncClient` (the file already has both variants): + +- **Schema mismatch.** 200 OK + `b"null"` body against a model expecting a dict → `DecodeError` raised. Assert `exc.response.status_code == 200`, `exc.model is MyModel`, `isinstance(exc.original, pydantic.ValidationError)`, `exc.__cause__ is exc.original`. +- **Malformed JSON.** 200 OK + `b"{not json"` → `DecodeError` raised; same assertions; original is a `pydantic.ValidationError` (TypeAdapter.validate_json folds both failure modes into ValidationError). +- **`except ClientError` catches.** A test that wraps the schema-mismatch case in `except httpware.ClientError as exc:` and asserts the handler matches and `isinstance(exc, DecodeError)` is true — proves the user-facing promise. + +### `tests/test_decoders_msgspec.py` + +Existing direct-decoder tests stay as-is. Add one seam-level case that swaps `MsgspecDecoder()` in via the `decoder=` constructor argument, runs the schema-mismatch and malformed-JSON cases above, and asserts `exc.original` is a `msgspec.ValidationError` or `msgspec.DecodeError` respectively. Proves the wrapping is decoder-agnostic. + +### `tests/test_decoders_pydantic.py` + +No change. Existing tests still assert that `PydanticDecoder.decode(...)` called directly raises `pydantic.ValidationError` — the decoder still does this; the wrapping happens at the seam, not inside the decoder. + +### `tests/test_public_api.py` + +Extend the public-symbol list to include `DecodeError`. + +### Out of scope (testing) + +No Hypothesis / property tests. The wrap is deterministic and has no meaningful state space. + +## Docs + +### `docs/errors.md` + +- Update the hierarchy diagram (the one mirroring the README/CLAUDE.md tree) to add `DecodeError` as a leaf sibling of `StatusError` under `ClientError`. +- Add a short "`DecodeError`" subsection following the same density as the existing `RetryBudgetExhaustedError` and `BulkheadFullError` sections. Three to five sentences explaining when it's raised (HTTP call succeeded, decoder failed); list the fields (`response`, `model`, `original`); one minimal `except` snippet. + +### `README.md` + +Current state (`README.md:54–67` and `README.md:74–87`): two `response_model=` examples exist, neither is wrapped in `try / except`. Do not add a new `try / except` block around either — these snippets are happy-path showcases and pulling them off-balance to demonstrate one error class hurts more than it helps. Instead, add a one-line note immediately after the `response_model=` paragraph (around `README.md:52`) explaining that decode failures raise `httpware.DecodeError` (a `ClientError` subclass), so the same `except httpware.ClientError` catches them. If a follow-up PR adds a dedicated errors section to the README, that section can carry the longer example. + +### `planning/engineering.md` + +- Add `DecodeError` to the exception-tree summary in the "Exception contract" section. +- Update the Seam-3 contract: "`decode` may raise any `Exception`; `Client.send` / `AsyncClient.send` wrap it as `DecodeError`. Decoder implementers do not need to raise `DecodeError` directly." + +### Out of scope (docs) + +- No new `docs/recipes/` entry. +- No `docs/decoders.md` — we do not have a decoders docs page today and this fix does not justify creating one. +- No migration guide — additive surface, no consumer code breaks at compile time. + +## Backward compatibility + +Purely additive at the import surface: `from httpware import DecodeError` is new but no existing import or name is changed. + +Behavior change at the runtime surface: code that today catches `pydantic.ValidationError` or `msgspec.ValidationError` / `msgspec.DecodeError` directly downstream of `client.send(...)` will no longer match — those exceptions are now wrapped. That is the intended fix. + +Code that today does `try: client.get(..., response_model=M) except httpware.ClientError: ...` continues to work and now actually catches the previously-escaping decode failure. + +## Release + +Target: **`0.8.1`** patch release. + +Release notes: + +- **Fix:** decoder exceptions from `response_model=` are now wrapped in a new `httpware.DecodeError` (a `ClientError` subclass), closing the gap where `pydantic.ValidationError` / `msgspec.ValidationError` / `msgspec.DecodeError` would escape `except httpware.ClientError`. +- **New:** `httpware.DecodeError` — direct child of `ClientError`. Fields: `response`, `model`, `original`. +- **Behavior change:** consumers catching pydantic/msgspec exceptions directly need to switch to `except httpware.DecodeError` (or the broader `except httpware.ClientError`). No shim layer; the previously-leaking exceptions weren't a documented contract. From 5b367b672a8a65124b91ceb9748fe11b52a3a270 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 7 Jun 2026 23:47:57 +0300 Subject: [PATCH 02/10] plan: DecodeError implementation (9 tasks, TDD) Drives the spec at planning/specs/2026-06-07-decoder-error-design.md into 9 bite-sized tasks: errors.py class, public re-export, both send() wraps, msgspec seam parity, protocol docstring, errors.md + engineering.md + README docs, final verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-06-07-decoder-error-plan.md | 923 ++++++++++++++++++ 1 file changed, 923 insertions(+) create mode 100644 planning/plans/2026-06-07-decoder-error-plan.md diff --git a/planning/plans/2026-06-07-decoder-error-plan.md b/planning/plans/2026-06-07-decoder-error-plan.md new file mode 100644 index 0000000..839ba37 --- /dev/null +++ b/planning/plans/2026-06-07-decoder-error-plan.md @@ -0,0 +1,923 @@ +# DecodeError 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:** Introduce `httpware.DecodeError` and wrap the two `_decoder.decode(...)` call sites in `Client.send` / `AsyncClient.send` so that any exception raised by a `ResponseDecoder` becomes a `ClientError` subclass — closing the gap where `pydantic.ValidationError` / `msgspec.ValidationError` / `msgspec.DecodeError` would escape `except httpware.ClientError`. + +**Architecture:** New `DecodeError(ClientError)` class in `errors.py` with keyword-only init carrying `response: httpx2.Response`, `model: type`, `original: BaseException`, plus `__reduce__` for pickle parity. The wrap happens at Seam B (`Client/AsyncClient ↔ ResponseDecoder`) in `client.py`: both `send` methods grow `try: ... except Exception as exc: raise DecodeError(...) from exc`. Decoder classes (`PydanticDecoder`, `MsgspecDecoder`) are unchanged. The `ResponseDecoder` protocol grows one docstring sentence, no signature change. + +**Tech Stack:** Python 3.11+, `httpx2`, pydantic 2.x / msgspec 0.18+ (optional extras), `pytest` + `pytest-asyncio` auto mode, `ty` for type checking, `ruff` for lint, `just` task runner. + +--- + +## Spec reference + +The validated spec is at `planning/specs/2026-06-07-decoder-error-design.md`. Read it before starting. Decisions locked there and not re-debated here: + +- `DecodeError` is a **direct child of `ClientError`** (sibling of `StatusError`, `TransportError`, etc.) — not under a new intermediate parent. +- The seam wrapper **catches `Exception`**, not `BaseException` — `KeyboardInterrupt` / `SystemExit` / `asyncio.CancelledError` (3.11+) propagate untouched. +- The `ResponseDecoder` protocol stays silent on exceptions; only the docstring grows one sentence. +- `PydanticDecoder` and `MsgspecDecoder` are unchanged. +- Init is keyword-only: `DecodeError(*, response, model, original)`. +- `original` is kept as an attribute even though `__cause__` carries the same reference. +- Message format: `f"failed to decode response into {model.__name__}: {original}"`. +- Target release: **0.8.1** (patch; the leaked exceptions weren't a documented contract). + +## File structure + +| Path | Operation | Responsibility | +|---|---|---| +| `src/httpware/errors.py` | modify | Add `_reconstruct_decode_error` + `DecodeError` class. | +| `src/httpware/client.py` | modify | Extend errors import; wrap `_decoder.decode(...)` call in both `Client.send` (line ~874) and `AsyncClient.send` (line ~157). | +| `src/httpware/__init__.py` | modify | Re-export `DecodeError`; add to `__all__`. | +| `src/httpware/decoders/__init__.py` | modify | Add one sentence to `ResponseDecoder.decode` docstring. | +| `tests/test_errors.py` | modify | Add construction, chaining, pickle, and inheritance tests for `DecodeError`. | +| `tests/test_client_response_model.py` | modify | **Delete** the obsolete `test_decoder_validation_error_propagates_unwrapped`; add seam-wrap tests (schema mismatch, malformed JSON, `except ClientError` catches) for both sync and async clients. | +| `tests/test_decoders_msgspec.py` | modify | Add one seam-level test that proves wrapping is decoder-agnostic (use `MsgspecDecoder()` through `AsyncClient`). | +| `tests/test_public_api.py` | modify | Add `DecodeError` to the `expected` symbol set. | +| `docs/errors.md` | modify | Add `DecodeError` leaf to the hierarchy diagram; add a `DecodeError` reference subsection. | +| `planning/engineering.md` | modify | Update Seam B contract and the §4 exception contract to mention `DecodeError` and the wrapping. | +| `README.md` | modify | One-line note after the `response_model=` paragraph mentioning `DecodeError`. | + +No new files. No file is deleted. + +## A note on TDD here + +This plan follows code-style TDD: each behavior change is exercised by a failing test first, the test is run to confirm it fails for the expected reason, then the minimal implementation is written, then the test is re-run to confirm it passes, then committed. Docs tasks (errors.md, engineering.md, README) are not TDD-able; they ship with a manual review step. + +--- + +## Task 1: Add `DecodeError` class to `errors.py` + +**Files:** +- Test: `tests/test_errors.py` (add cases) +- Modify: `src/httpware/errors.py` (add class + `_reconstruct_decode_error`) + +- [ ] **Step 1: Add failing tests to `tests/test_errors.py`** + +Append to `tests/test_errors.py`. First extend the `from httpware.errors import (...)` block (currently at lines 9–29) to include `DecodeError` between `ConflictError` and `ForbiddenError` (alphabetical), then append the new tests at the bottom of the file: + +```python +import pydantic + + +class _DecodeErrorModel(pydantic.BaseModel): + id: int + + +def _make_ok_response(*, url: str = "https://example.test/x") -> httpx2.Response: + request = httpx2.Request("GET", url) + return httpx2.Response(200, content=b'{"id": 1}', request=request) + + +def test_decode_error_is_client_error() -> None: + response = _make_ok_response() + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + assert isinstance(exc, ClientError) + + +def test_decode_error_stores_fields() -> None: + response = _make_ok_response() + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + assert exc.response is response + assert exc.model is _DecodeErrorModel + assert exc.original is inner + + +def test_decode_error_summary_includes_model_and_original() -> None: + response = _make_ok_response() + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + summary = str(exc) + assert "_DecodeErrorModel" in summary + assert "bad payload" in summary + assert summary.startswith("failed to decode response into ") + + +def test_decode_error_rejects_positional_args() -> None: + response = _make_ok_response() + inner = ValueError("bad payload") + with pytest.raises(TypeError): + DecodeError(response, _DecodeErrorModel, inner) # type: ignore[misc] + + +def test_decode_error_chaining_via_raise_from() -> None: + response = _make_ok_response() + inner = ValueError("bad payload") + try: + try: + raise inner + except ValueError as caught: + raise DecodeError(response=response, model=_DecodeErrorModel, original=caught) from caught + except DecodeError as exc: + assert exc.__cause__ is inner + assert exc.original is inner + + +def test_decode_error_pickleable() -> None: + response = _make_ok_response(url="https://example.test/p") + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + restored = pickle.loads(pickle.dumps(exc)) # noqa: S301 + assert isinstance(restored, DecodeError) + assert restored.model is _DecodeErrorModel + assert isinstance(restored.original, ValueError) + assert str(restored.original) == "bad payload" + assert restored.response.status_code == 200 # noqa: PLR2004 +``` + +Then extend the `test_inheritance_tree` function (currently around line 37) by adding one line inside the function: + +```python +assert issubclass(DecodeError, ClientError) +``` + +The `import pydantic` goes at the top of the test file with the other imports (currently `import builtins`, `import pickle`, `import httpx2`, `import pytest`). Add `_DecodeErrorModel` and `_make_ok_response` near the existing `_make_response` helper (around line 32). + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/test_errors.py -v -k decode_error +``` + +Expected: `ImportError` from the `from httpware.errors import (..., DecodeError, ...)` line at module load — the test collection phase fails before any test runs. That's fine; it's the "function not defined" equivalent for a missing class. + +- [ ] **Step 3: Implement `DecodeError` in `src/httpware/errors.py`** + +Append to `src/httpware/errors.py` (after the `BulkheadFullError` block, before the file ends): + +```python +def _reconstruct_decode_error( + cls: "type[DecodeError]", + response: httpx2.Response, + model: type, + original: BaseException, +) -> "DecodeError": + return cls(response=response, model=model, original=original) + + +class DecodeError(ClientError): + """Raised when the active ResponseDecoder failed to decode response.content. + + The HTTP call itself succeeded — status was 2xx/3xx and the transport + delivered the body intact — but the body could not be parsed into the + requested response_model. Always chained from the underlying library + exception via ``raise ... from exc``; that exception is also exposed as + ``self.original`` for structured handling. + """ + + response: httpx2.Response + model: type + original: BaseException + + def __init__( + self, + *, + response: httpx2.Response, + model: type, + original: BaseException, + ) -> None: + self.response = response + self.model = model + self.original = original + super().__init__(f"failed to decode response into {model.__name__}: {original}") + + def __reduce__(self) -> tuple[Any, ...]: + return ( + _reconstruct_decode_error, + (type(self), self.response, self.model, self.original), + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/test_errors.py -v +``` + +Expected: all decode-error tests pass; pre-existing tests stay green. + +- [ ] **Step 5: Run lint and type-check** + +```bash +just lint +``` + +Expected: clean. If `ty` complains about `original: BaseException` field shadowing — it shouldn't, but if it does, the suppression pattern is `# ty: ignore[]` per `CLAUDE.md`. + +- [ ] **Step 6: Commit** + +```bash +git add src/httpware/errors.py tests/test_errors.py +git commit -m "$(cat <<'EOF' +errors: add DecodeError for ResponseDecoder failures + +DecodeError is a direct child of ClientError carrying the response, +model, and original library exception. Construction-only here; the +client.send wrap follows in the next commit. +EOF +)" +``` + +--- + +## Task 2: Re-export `DecodeError` from `httpware/__init__.py` + +**Files:** +- Test: `tests/test_public_api.py` (extend `expected` set) +- Modify: `src/httpware/__init__.py` + +- [ ] **Step 1: Add failing test** + +Edit `tests/test_public_api.py:30–68`. Add `"DecodeError",` to the `expected` set (alphabetical — between `"ConflictError"` and `"ForbiddenError"`): + +```python +def test_expected_exports() -> None: + expected = { + "AsyncBulkhead", + "AsyncClient", + "AsyncMiddleware", + "AsyncNext", + "AsyncRetry", + "BadRequestError", + "Bulkhead", + "BulkheadFullError", + "Client", + "ClientError", + "ClientStatusError", + "ConflictError", + "DecodeError", + "ForbiddenError", + # ... rest unchanged +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +uv run pytest tests/test_public_api.py -v +``` + +Expected: `test_expected_exports` fails with `AssertionError: expected exports missing from __all__: {'DecodeError'}`. `test_all_exports_resolve` continues to pass (the `expected` set is checked separately). + +- [ ] **Step 3: Add `DecodeError` to `__init__.py`** + +Edit `src/httpware/__init__.py:5–25`. The errors-import block currently lists symbols alphabetically. Add `DecodeError` between `ConflictError` and `ForbiddenError`: + +```python +from httpware.errors import ( + STATUS_TO_EXCEPTION, + BadRequestError, + BulkheadFullError, + ClientError, + ClientStatusError, + ConflictError, + DecodeError, + ForbiddenError, + InternalServerError, + NetworkError, + NotFoundError, + RateLimitedError, + RetryBudgetExhaustedError, + ServerStatusError, + ServiceUnavailableError, + StatusError, + TimeoutError, # noqa: A004 + TransportError, + UnauthorizedError, + UnprocessableEntityError, +) +``` + +Add `"DecodeError",` to `__all__` (line 41+), alphabetically — between `"ConflictError"` and `"ForbiddenError"`: + +```python +__all__ = [ + "STATUS_TO_EXCEPTION", + # ... existing entries ... + "ConflictError", + "DecodeError", + "ForbiddenError", + # ... rest unchanged +] +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +uv run pytest tests/test_public_api.py -v +``` + +Expected: all three public-API tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/httpware/__init__.py tests/test_public_api.py +git commit -m "$(cat <<'EOF' +errors: re-export DecodeError from httpware top-level + +Adds DecodeError to httpware.__init__'s errors import block and __all__, +plus the explicit expected-exports test. No behavior change yet. +EOF +)" +``` + +--- + +## Task 3: Wrap the decoder call in both `send` methods + +**Files:** +- Test: `tests/test_client_response_model.py` (delete obsolete test; add seam-wrap tests) +- Modify: `src/httpware/client.py` (extend errors import; wrap both `send` decoder calls) + +- [ ] **Step 1: Delete the obsolete test** + +In `tests/test_client_response_model.py`, **delete** the existing test at lines 50–53 in full: + +```python +async def test_decoder_validation_error_propagates_unwrapped() -> None: + client = _client_with_payload(b'{"id": "not-an-int", "name": "x"}') + with pytest.raises(pydantic.ValidationError): + await client.get("https://example.test/u", response_model=_User) +``` + +This test asserts the *previous* (broken) behavior — that `pydantic.ValidationError` escapes unwrapped. It is replaced by the seam-wrap tests below. + +- [ ] **Step 2: Add failing seam-wrap tests** + +Extend `tests/test_client_response_model.py`. First add the `Client` import to the existing import line (currently `from httpware import AsyncClient, NotFoundError`) and add `DecodeError`: + +```python +from httpware import AsyncClient, Client, DecodeError, NotFoundError +``` + +(`ClientError` belongs at module top-level — do not add it inside a test function with `# noqa: PLC0415`.) Extend the import: + +```python +from httpware import AsyncClient, Client, ClientError, DecodeError, NotFoundError +``` + +Then add a sync mock-transport helper next to the existing `_client_with_payload` (which currently returns `AsyncClient` only): + +```python +def _sync_client_with_payload(payload: bytes, content_type: str = "application/json") -> Client: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + HTTPStatus.OK, + content=payload, + headers={"content-type": content_type}, + request=request, + ) + + transport = httpx2.MockTransport(handler) + return Client(httpx2_client=httpx2.Client(transport=transport)) +``` + +Then append the new tests at the end of the file: + +```python +async def test_async_schema_mismatch_raises_decode_error() -> None: + client = _client_with_payload(b"null") + with pytest.raises(DecodeError) as exc_info: + await client.get("https://example.test/u", response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + assert exc.__cause__ is exc.original + + +async def test_async_malformed_json_raises_decode_error() -> None: + client = _client_with_payload(b"{not json") + with pytest.raises(DecodeError) as exc_info: + await client.get("https://example.test/u", response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + + +async def test_async_decode_error_caught_by_client_error() -> None: + """The user-facing promise: `except ClientError` catches decode failures.""" + client = _client_with_payload(b"null") + try: + await client.get("https://example.test/u", response_model=_User) + except ClientError as exc: + assert isinstance(exc, DecodeError) + else: + pytest.fail("expected DecodeError to be raised") + + +def test_sync_schema_mismatch_raises_decode_error() -> None: + client = _sync_client_with_payload(b"null") + with pytest.raises(DecodeError) as exc_info: + client.get("https://example.test/u", response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + + +def test_sync_malformed_json_raises_decode_error() -> None: + client = _sync_client_with_payload(b"{not json") + with pytest.raises(DecodeError): + client.get("https://example.test/u", response_model=_User) +``` + + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +uv run pytest tests/test_client_response_model.py -v +``` + +Expected: all five new tests fail with `pydantic.ValidationError` (or `pydantic_core._pydantic_core.ValidationError`) instead of `DecodeError` — because the seam wrap is not yet in place. + +- [ ] **Step 4: Extend the errors import in `client.py`** + +Edit `src/httpware/client.py:19`. Currently: + +```python +from httpware.errors import TransportError +``` + +Change to: + +```python +from httpware.errors import DecodeError, TransportError +``` + +- [ ] **Step 5: Wrap the async `send` decoder call** + +Edit `src/httpware/client.py`. The async `send` method is at line 147; the unguarded decode call is at line 157. Replace: + +```python +async def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, +) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + response = await self._dispatch(request) + if response_model is None: + return response + return self._decoder.decode(response.content, response_model) +``` + +with: + +```python +async def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, +) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + response = await self._dispatch(request) + if response_model is None: + return response + try: + return self._decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc +``` + +Critical: `await self._dispatch(request)` stays **outside** the try — transport/status errors are already mapped to `ClientError` subclasses by `_terminal` and should not be re-wrapped as `DecodeError`. + +- [ ] **Step 6: Wrap the sync `send` decoder call** + +Edit `src/httpware/client.py`. The sync `send` method is at line 864; the unguarded decode call is at line 874. Replace: + +```python +def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, +) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + response = self._dispatch(request) + if response_model is None: + return response + return self._decoder.decode(response.content, response_model) +``` + +with: + +```python +def send( + self, + request: httpx2.Request, + *, + response_model: type[T] | None = None, +) -> httpx2.Response | T: + """Send `request` through the middleware chain. Decode if `response_model` is set.""" + response = self._dispatch(request) + if response_model is None: + return response + try: + return self._decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc +``` + +Same `self._dispatch(request)` outside-the-try rule. + +- [ ] **Step 7: Run tests to verify they pass** + +```bash +uv run pytest tests/test_client_response_model.py -v +``` + +Expected: all tests pass (the five new ones plus the surviving original four). + +- [ ] **Step 8: Run lint and type-check** + +```bash +just lint +``` + +Expected: clean. If `ruff` flags `except Exception` with `BLE001` (broad-except), the suppression line is `# noqa: BLE001 — decoder-specific exceptions are wrapped as DecodeError at this seam`. Verify by running first; only add the noqa if ruff actually flags it. + +- [ ] **Step 9: Commit** + +```bash +git add src/httpware/client.py tests/test_client_response_model.py +git commit -m "$(cat <<'EOF' +client: wrap decoder exceptions as DecodeError at seam B + +Both Client.send and AsyncClient.send now translate any Exception +raised by the active ResponseDecoder into httpware.DecodeError, so +`except httpware.ClientError` covers the response_model= path +uniformly regardless of which decoder is wired in. + +Drops the previous test_decoder_validation_error_propagates_unwrapped +case which encoded the now-fixed leak. +EOF +)" +``` + +--- + +## Task 4: Prove the wrap is decoder-agnostic (msgspec seam test) + +**Files:** +- Test: `tests/test_decoders_msgspec.py` (append one seam-level test) + +- [ ] **Step 1: Add a seam-level msgspec test** + +Append to `tests/test_decoders_msgspec.py`. Extend the existing imports: + +```python +import httpx2 +from http import HTTPStatus + +from httpware import AsyncClient, DecodeError +``` + +Then append the test: + +```python +async def test_msgspec_decoder_failures_wrap_as_decode_error_at_seam() -> None: + """Proves wrapping is decoder-agnostic: switching to MsgspecDecoder still yields DecodeError.""" + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, content=b"{not json", request=request) + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoder=MsgspecDecoder(), + ) + with pytest.raises(DecodeError) as exc_info: + await client.get("https://example.test/x", response_model=_Item) + exc = exc_info.value + assert exc.model is _Item + assert isinstance(exc.original, (msgspec.DecodeError, msgspec.ValidationError)) +``` + +The existing direct-decoder tests (`test_decode_validation_error_propagates`, `test_decode_json_parse_error_propagates`) stay as-is — they test the decoder, not the seam. + +- [ ] **Step 2: Run tests to verify they pass** + +```bash +uv run pytest tests/test_decoders_msgspec.py -v +``` + +Expected: all pass. The seam wrap was already added in Task 3; this test only proves it works for a non-default decoder. + +- [ ] **Step 3: Run lint and type-check** + +```bash +just lint +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_decoders_msgspec.py +git commit -m "$(cat <<'EOF' +tests: prove DecodeError wrap is decoder-agnostic via msgspec + +Seam-level test wires MsgspecDecoder into AsyncClient and asserts a +malformed-JSON response still surfaces as httpware.DecodeError with +exc.original carrying the underlying msgspec exception. +EOF +)" +``` + +--- + +## Task 5: Update the `ResponseDecoder` protocol docstring + +**Files:** +- Modify: `src/httpware/decoders/__init__.py` + +- [ ] **Step 1: Extend the docstring** + +Edit `src/httpware/decoders/__init__.py:13–15`. Replace: + +```python +def decode(self, content: bytes, model: type[T]) -> T: + """Decode `content` (raw response bytes) into an instance of `model`.""" + ... +``` + +with: + +```python +def decode(self, content: bytes, model: type[T]) -> T: + """Decode `content` (raw response bytes) into an instance of `model`. + + Any exception raised by `decode` is wrapped by `Client.send` / + `AsyncClient.send` into `httpware.DecodeError`; implementers do not + need to raise `DecodeError` directly. + """ + ... +``` + +- [ ] **Step 2: Run the full test suite + lint** + +```bash +uv run pytest -q +just lint +``` + +Expected: clean. This is a docstring-only change; nothing else should move. + +- [ ] **Step 3: Commit** + +```bash +git add src/httpware/decoders/__init__.py +git commit -m "$(cat <<'EOF' +decoders: document the DecodeError seam wrap on ResponseDecoder + +One-sentence addition: implementers can raise whatever their backing +library raises; Client.send / AsyncClient.send translate to +httpware.DecodeError at the seam. +EOF +)" +``` + +--- + +## Task 6: Update `docs/errors.md` + +**Files:** +- Modify: `docs/errors.md` + +- [ ] **Step 1: Add `DecodeError` to the hierarchy diagram** + +Edit `docs/errors.md:11–30`. Add `DecodeError` as a sibling leaf under `ClientError`, after `BulkheadFullError`: + +```text +ClientError (catch-all for anything httpware raises) +├── TransportError (connection/network/protocol failure pre-response) +│ └── NetworkError (transient — safe to retry; covered by AsyncRetry's defaults) +├── TimeoutError (also inherits builtins.TimeoutError — except OSError catches it) +├── StatusError (got a response but its status was 4xx/5xx) +│ ├── ClientStatusError (any 4xx — fallback for unknown 4xx codes) +│ │ ├── BadRequestError (400) +│ │ ├── UnauthorizedError (401) +│ │ ├── ForbiddenError (403) +│ │ ├── NotFoundError (404) +│ │ ├── ConflictError (409) +│ │ ├── UnprocessableEntityError (422) +│ │ └── RateLimitedError (429) +│ └── ServerStatusError (any 5xx — fallback for unknown 5xx codes) +│ ├── InternalServerError (500) +│ └── ServiceUnavailableError (503) +├── RetryBudgetExhaustedError (a retry was needed but the budget refused) +├── BulkheadFullError (acquire_timeout elapsed before a slot opened) +└── DecodeError (response_model= decoder failed; HTTP call itself succeeded) +``` + +- [ ] **Step 2: Add a `DecodeError` reference subsection** + +Insert a new subsection between "Resilience-error payloads" (currently line ~109) and "See also" (currently line ~131). New subsection text: + +```markdown +## `DecodeError` + +`DecodeError` is raised when `response_model=` is set on a request and the active `ResponseDecoder` failed to parse the response body. The HTTP call itself succeeded — status was 2xx/3xx and the transport delivered the body intact — but the body could not be coerced into the requested model. The exception is raised independently of which decoder is in use (`PydanticDecoder`, `MsgspecDecoder`, or a third-party adapter), so `except httpware.ClientError` is sufficient to cover the response-model decode path. + +Fields: + +- `response: httpx2.Response` — the response whose body failed to decode. Status, headers, and the originating `request` are all available via `exc.response.*`. +- `model: type` — the type that was passed as `response_model=`. +- `original: BaseException` — the underlying library exception (e.g., `pydantic.ValidationError`, `msgspec.ValidationError`, `msgspec.DecodeError`). Also available via `exc.__cause__`. + +```python +from httpware import AsyncClient, DecodeError + + +try: + user = await client.get("/users/1", response_model=User) +except DecodeError as exc: + _LOGGER.error( + "decode failed for %s into %s: %s", + exc.response.request.url, + exc.model.__name__, + exc.original, + ) + raise +``` +``` + +- [ ] **Step 3: Manual review of the docs** + +Open `docs/errors.md` and skim the result. Check: +- Hierarchy diagram is balanced (the `└──` and `├──` characters line up). +- The new section sits between "Resilience-error payloads" and "See also" — not inside either. +- The code block in the new section closes cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add docs/errors.md +git commit -m "$(cat <<'EOF' +docs: document DecodeError in the errors reference + +Adds DecodeError to the exception-tree diagram and a new subsection +covering when it's raised, what fields it carries, and a minimal +except snippet. +EOF +)" +``` + +--- + +## Task 7: Update `planning/engineering.md` + +**Files:** +- Modify: `planning/engineering.md` + +- [ ] **Step 1: Update the Seam B contract** + +Edit `planning/engineering.md` Seam B section (currently around lines 39–43). Replace: + +```markdown +### Seam B: `AsyncClient ↔ ResponseDecoder` + +- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. +- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Decoder errors (`pydantic.ValidationError`, `msgspec.ValidationError`) propagate unwrapped. +- **Rule:** the decoder must operate on raw bytes in a single parse pass. ... +``` + +with: + +```markdown +### Seam B: `Client`/`AsyncClient` ↔ `ResponseDecoder` + +- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. +- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Any exception raised by `decode` is wrapped by `Client.send` / `AsyncClient.send` into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly. +- **Rule:** the decoder must operate on raw bytes in a single parse pass. ... +``` + +(Keep the rest of the Rule paragraph verbatim — only the Contract line changes; the Where line picks up both worlds.) + +- [ ] **Step 2: Update §4 exception contract** + +Edit the "## 4. Exception contract" section (currently starts at line ~52). After the existing paragraph about `TimeoutError` (line ~66), append: + +```markdown +`DecodeError` covers the case where `response_model=` is set, the HTTP call itself succeeded, but the active `ResponseDecoder` raised. The wrap happens at the seam in `Client.send` / `AsyncClient.send` — `except Exception` translates any decoder-side failure into `DecodeError(response=..., model=..., original=...)` with `raise ... from exc` chaining. The `original` attribute exposes the underlying library exception (e.g., `pydantic.ValidationError`, `msgspec.ValidationError`); `__cause__` carries the same reference. +``` + +- [ ] **Step 3: Commit** + +```bash +git add planning/engineering.md +git commit -m "$(cat <<'EOF' +engineering: document DecodeError + seam B wrap + +Updates the Seam B contract to spell out the wrap, and adds a +paragraph to the §4 exception contract describing when DecodeError +is raised and what fields it carries. +EOF +)" +``` + +--- + +## Task 8: Add a README note + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add the one-line note** + +Edit `README.md`. After the line at `README.md:52`: + +```markdown +Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`: +``` + +…insert a new line directly below (above the `from httpware import AsyncClient` block at line 55): + +```markdown +Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors. +``` + +(The merge collapses the original sentence and the new one onto a single paragraph; the `from httpware import AsyncClient` example below remains unchanged.) + +- [ ] **Step 2: Visual review** + +Open `README.md` and confirm: +- The paragraph reads naturally as one sentence flowing into the next. +- No widows / orphan line breaks in markdown. +- The code block below is unaffected. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "$(cat <<'EOF' +docs: note DecodeError in the README response_model paragraph + +One-line addition: response_model= decode failures raise +httpware.DecodeError (a ClientError subclass), so the standard +except httpware.ClientError catches them. +EOF +)" +``` + +--- + +## Task 9: Final verification + +**Files:** none modified. Pure verification. + +- [ ] **Step 1: Run the full test suite** + +```bash +just test +``` + +Expected: every test in `tests/` passes; the run reports coverage (100% line coverage is the target — `planning/engineering.md` §6). + +- [ ] **Step 2: Run the CI-shape lint** + +```bash +just lint-ci +``` + +Expected: clean. This matches what CI runs; it does **not** auto-fix. + +- [ ] **Step 3: Verify the architectural invariants still hold** + +```bash +grep -rE 'httpx2\._' src/httpware/ +grep -rn 'from __future__ import annotations' src/httpware/ +grep -rn 'print(' src/httpware/ +``` + +Expected: each returns zero matches. These are the CI-enforced invariants from `CLAUDE.md`. + +- [ ] **Step 4: Verify the public API** + +```bash +uv run python -c "import httpware; print(httpware.DecodeError); print(issubclass(httpware.DecodeError, httpware.ClientError))" +``` + +Expected output: + +```text + +True +``` + +- [ ] **Step 5: Spot-check coverage of the new code** + +```bash +uv run pytest tests/test_errors.py tests/test_client_response_model.py tests/test_decoders_msgspec.py tests/test_public_api.py --cov=httpware.errors --cov=httpware.client --cov-report=term-missing +``` + +Expected: the new `DecodeError` class and the two `try/except` blocks in `client.py` show 100% line coverage. If any line is unhit, add a test before claiming done. + +- [ ] **Step 6: Confirm release readiness** + +The work is now ready to ship as `0.8.1`. No version bump happens in this plan — release-cutting is a separate manual step per the project's existing convention (bare-semver git tag, no CHANGELOG file, release notes on GitHub Releases). From 31d1c765538f8d0fc000920dbacda1f6edbb19cb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 7 Jun 2026 23:55:37 +0300 Subject: [PATCH 03/10] errors: add DecodeError for ResponseDecoder failures DecodeError is a direct child of ClientError carrying the response, model, and original library exception. Construction-only here; the client.send wrap follows in the next commit. --- src/httpware/errors.py | 42 +++++++++++++++++++++++++ tests/test_errors.py | 69 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/httpware/errors.py b/src/httpware/errors.py index dad8e26..7d03180 100644 --- a/src/httpware/errors.py +++ b/src/httpware/errors.py @@ -212,3 +212,45 @@ def __reduce__(self) -> tuple[Any, ...]: _reconstruct_bulkhead_full, (type(self), self.max_concurrent, self.acquire_timeout), ) + + +def _reconstruct_decode_error( + cls: "type[DecodeError]", + response: httpx2.Response, + model: type, + original: BaseException, +) -> "DecodeError": + return cls(response=response, model=model, original=original) + + +class DecodeError(ClientError): + """Raised when the active ResponseDecoder failed to decode response.content. + + The HTTP call itself succeeded — status was 2xx/3xx and the transport + delivered the body intact — but the body could not be parsed into the + requested response_model. Always chained from the underlying library + exception via ``raise ... from exc``; that exception is also exposed as + ``self.original`` for structured handling. + """ + + response: httpx2.Response + model: type + original: BaseException + + def __init__( + self, + *, + response: httpx2.Response, + model: type, + original: BaseException, + ) -> None: + self.response = response + self.model = model + self.original = original + super().__init__(f"failed to decode response into {model.__name__}: {original}") + + def __reduce__(self) -> tuple[Any, ...]: + return ( + _reconstruct_decode_error, + (type(self), self.response, self.model, self.original), + ) diff --git a/tests/test_errors.py b/tests/test_errors.py index 18f0efc..0d39669 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,8 +2,10 @@ import builtins import pickle +from http import HTTPStatus import httpx2 +import pydantic import pytest from httpware.errors import ( @@ -13,6 +15,7 @@ ClientError, ClientStatusError, ConflictError, + DecodeError, ForbiddenError, InternalServerError, NetworkError, @@ -34,6 +37,10 @@ def _make_response(status: int, *, url: str = "https://example.test/x", method: return httpx2.Response(status, request=request) +class _DecodeErrorModel(pydantic.BaseModel): + id: int + + def test_inheritance_tree() -> None: assert issubclass(StatusError, ClientError) assert issubclass(TransportError, ClientError) @@ -41,6 +48,7 @@ def test_inheritance_tree() -> None: assert issubclass(TimeoutError, builtins.TimeoutError) assert issubclass(ClientStatusError, StatusError) assert issubclass(ServerStatusError, StatusError) + assert issubclass(DecodeError, ClientError) for exc in ( BadRequestError, UnauthorizedError, @@ -236,3 +244,64 @@ def test_bulkhead_full_error_pickleable() -> None: assert isinstance(restored, BulkheadFullError) assert restored.max_concurrent == _MAX_CONCURRENT_5 assert restored.acquire_timeout == _ACQUIRE_TIMEOUT_1_0 + + +def test_decode_error_is_client_error() -> None: + response = _make_response(200) + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + assert isinstance(exc, ClientError) + + +def test_decode_error_stores_fields() -> None: + response = _make_response(200) + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + assert exc.response is response + assert exc.model is _DecodeErrorModel + assert exc.original is inner + + +def test_decode_error_summary_includes_model_and_original() -> None: + response = _make_response(200) + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + summary = str(exc) + assert "_DecodeErrorModel" in summary + assert "bad payload" in summary + assert summary.startswith("failed to decode response into ") + + +def test_decode_error_rejects_positional_args() -> None: + response = _make_response(200) + inner = ValueError("bad payload") + with pytest.raises(TypeError): + DecodeError(response, _DecodeErrorModel, inner) # ty: ignore[missing-argument, too-many-positional-arguments] + + +def test_decode_error_chaining_via_raise_from() -> None: + response = _make_response(200) + inner = ValueError("bad payload") + raised: DecodeError | None = None + try: + try: + raise inner + except ValueError as caught: + raise DecodeError(response=response, model=_DecodeErrorModel, original=caught) from caught + except DecodeError as exc: + raised = exc + assert raised is not None + assert raised.__cause__ is inner + assert raised.original is inner + + +def test_decode_error_pickleable() -> None: + response = _make_response(200, url="https://example.test/p") + inner = ValueError("bad payload") + exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner) + restored = pickle.loads(pickle.dumps(exc)) # noqa: S301 + assert isinstance(restored, DecodeError) + assert restored.model is _DecodeErrorModel + assert isinstance(restored.original, ValueError) + assert str(restored.original) == "bad payload" + assert restored.response.status_code == HTTPStatus.OK From 10251a408c3dcef8f799488ed7ed43c023898428 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 00:05:23 +0300 Subject: [PATCH 04/10] errors: re-export DecodeError from httpware top-level Adds DecodeError to httpware.__init__'s errors import block and __all__, plus the explicit expected-exports test. No behavior change yet. --- src/httpware/__init__.py | 2 ++ tests/test_public_api.py | 1 + 2 files changed, 3 insertions(+) diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index a2a9dd8..88f18df 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -9,6 +9,7 @@ ClientError, ClientStatusError, ConflictError, + DecodeError, ForbiddenError, InternalServerError, NetworkError, @@ -52,6 +53,7 @@ "ClientError", "ClientStatusError", "ConflictError", + "DecodeError", "ForbiddenError", "InternalServerError", "Middleware", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index ba3d2fd..a1f234a 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -40,6 +40,7 @@ def test_expected_exports() -> None: "ClientError", "ClientStatusError", "ConflictError", + "DecodeError", "ForbiddenError", "InternalServerError", "Middleware", From 15770b1a0d9a353f1d4736e363e48363796c5ee7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 00:09:48 +0300 Subject: [PATCH 05/10] client: wrap decoder exceptions as DecodeError at seam B Both Client.send and AsyncClient.send now translate any Exception raised by the active ResponseDecoder into httpware.DecodeError, so `except httpware.ClientError` covers the response_model= path uniformly regardless of which decoder is wired in. Drops the previous test_decoder_validation_error_propagates_unwrapped case which encoded the now-fixed leak. --- src/httpware/client.py | 12 +++-- tests/test_client_response_model.py | 70 ++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/httpware/client.py b/src/httpware/client.py index 3527564..11820cc 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -16,7 +16,7 @@ _raise_on_status_error, ) from httpware.decoders import ResponseDecoder -from httpware.errors import TransportError +from httpware.errors import DecodeError, TransportError from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next from httpware.middleware.chain import compose, compose_async @@ -154,7 +154,10 @@ async def send( response = await self._dispatch(request) if response_model is None: return response - return self._decoder.decode(response.content, response_model) + try: + return self._decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: """Delegate request construction to the wrapped httpx2.AsyncClient.""" @@ -871,7 +874,10 @@ def send( response = self._dispatch(request) if response_model is None: return response - return self._decoder.decode(response.content, response_model) + try: + return self._decoder.decode(response.content, response_model) + except Exception as exc: + raise DecodeError(response=response, model=response_model, original=exc) from exc def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: """Delegate request construction to the wrapped httpx2.Client.""" diff --git a/tests/test_client_response_model.py b/tests/test_client_response_model.py index 3ef028c..82e5263 100644 --- a/tests/test_client_response_model.py +++ b/tests/test_client_response_model.py @@ -6,7 +6,7 @@ import pydantic import pytest -from httpware import AsyncClient, NotFoundError +from httpware import AsyncClient, Client, ClientError, DecodeError, NotFoundError class _User(pydantic.BaseModel): @@ -27,6 +27,19 @@ def handler(request: httpx2.Request) -> httpx2.Response: return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) +def _sync_client_with_payload(payload: bytes, content_type: str = "application/json") -> Client: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + HTTPStatus.OK, + content=payload, + headers={"content-type": content_type}, + request=request, + ) + + transport = httpx2.MockTransport(handler) + return Client(httpx2_client=httpx2.Client(transport=transport)) + + async def test_get_with_response_model_returns_typed_object() -> None: client = _client_with_payload(b'{"id": 1, "name": "ada"}') user = await client.get("https://example.test/u", response_model=_User) @@ -47,12 +60,6 @@ async def test_send_with_response_model_returns_typed_object() -> None: assert isinstance(user, _User) -async def test_decoder_validation_error_propagates_unwrapped() -> None: - client = _client_with_payload(b'{"id": "not-an-int", "name": "x"}') - with pytest.raises(pydantic.ValidationError): - await client.get("https://example.test/u", response_model=_User) - - async def test_status_error_raised_before_decoder_runs() -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(HTTPStatus.NOT_FOUND, content=b'{"id": 1, "name": "x"}', request=request) @@ -61,3 +68,52 @@ def handler(request: httpx2.Request) -> httpx2.Response: client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) with pytest.raises(NotFoundError): await client.get("https://example.test/u", response_model=_User) + + +async def test_async_schema_mismatch_raises_decode_error() -> None: + client = _client_with_payload(b"null") + with pytest.raises(DecodeError) as exc_info: + await client.get("https://example.test/u", response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + assert exc.__cause__ is exc.original + + +async def test_async_malformed_json_raises_decode_error() -> None: + client = _client_with_payload(b"{not json") + with pytest.raises(DecodeError) as exc_info: + await client.get("https://example.test/u", response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + + +async def test_async_decode_error_caught_by_client_error() -> None: + """The user-facing promise: `except ClientError` catches decode failures.""" + client = _client_with_payload(b"null") + with pytest.raises(ClientError) as exc_info: + await client.get("https://example.test/u", response_model=_User) + assert isinstance(exc_info.value, DecodeError) + + +def test_sync_schema_mismatch_raises_decode_error() -> None: + client = _sync_client_with_payload(b"null") + with pytest.raises(DecodeError) as exc_info: + client.get("https://example.test/u", response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) + + +def test_sync_malformed_json_raises_decode_error() -> None: + client = _sync_client_with_payload(b"{not json") + with pytest.raises(DecodeError) as exc_info: + client.get("https://example.test/u", response_model=_User) + exc = exc_info.value + assert exc.response.status_code == HTTPStatus.OK + assert exc.model is _User + assert isinstance(exc.original, pydantic.ValidationError) From 7c89dbdadc1493c290315752cc5ea77a138e27df Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 00:16:14 +0300 Subject: [PATCH 06/10] tests: prove DecodeError wrap is decoder-agnostic via msgspec Seam-level test wires MsgspecDecoder into AsyncClient and asserts a malformed-JSON response still surfaces as httpware.DecodeError with exc.original carrying the underlying msgspec exception. --- tests/test_decoders_msgspec.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_decoders_msgspec.py b/tests/test_decoders_msgspec.py index 71b328f..c030902 100644 --- a/tests/test_decoders_msgspec.py +++ b/tests/test_decoders_msgspec.py @@ -1,8 +1,12 @@ """Unit tests for httpware.decoders.msgspec.MsgspecDecoder.""" +from http import HTTPStatus + +import httpx2 import msgspec import pytest +from httpware import AsyncClient, DecodeError from httpware._internal import import_checker from httpware.decoders import ResponseDecoder from httpware.decoders.msgspec import MsgspecDecoder @@ -48,3 +52,21 @@ def test_construction_raises_without_extra_via_monkeypatch( monkeypatch.setattr(import_checker, "is_msgspec_installed", False) with pytest.raises(ImportError, match="MsgspecDecoder requires the 'msgspec' extra"): MsgspecDecoder() + + +async def test_msgspec_decoder_failures_wrap_as_decode_error_at_seam() -> None: + """Proves wrapping is decoder-agnostic: switching to MsgspecDecoder still yields DecodeError.""" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, content=b"{not json", request=request) + + transport = httpx2.MockTransport(handler) + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + decoder=MsgspecDecoder(), + ) + with pytest.raises(DecodeError) as exc_info: + await client.get("https://example.test/x", response_model=_Item) + exc = exc_info.value + assert exc.model is _Item + assert isinstance(exc.original, (msgspec.DecodeError, msgspec.ValidationError)) From 2016e496e4b807b14aa8bb0a65e2d494a6510078 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 00:17:51 +0300 Subject: [PATCH 07/10] decoders: document the DecodeError seam wrap on ResponseDecoder One-sentence addition: implementers can raise whatever their backing library raises; Client.send / AsyncClient.send translate to httpware.DecodeError at the seam. --- src/httpware/decoders/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/httpware/decoders/__init__.py b/src/httpware/decoders/__init__.py index 7b0e568..6f27a82 100644 --- a/src/httpware/decoders/__init__.py +++ b/src/httpware/decoders/__init__.py @@ -11,7 +11,12 @@ class ResponseDecoder(Protocol): """Structural protocol every response-body decoder satisfies.""" def decode(self, content: bytes, model: type[T]) -> T: - """Decode `content` (raw response bytes) into an instance of `model`.""" + """Decode `content` (raw response bytes) into an instance of `model`. + + Any exception raised by `decode` is wrapped by `Client.send` / + `AsyncClient.send` into `httpware.DecodeError`; implementers do not + need to raise `DecodeError` directly. + """ ... From b5e0c8e77e45fb18c63ca30b8bfad44606c34fbd Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 00:19:26 +0300 Subject: [PATCH 08/10] docs: document DecodeError in the errors reference Adds DecodeError to the exception-tree diagram and a new subsection covering when it's raised, what fields it carries, and a minimal except snippet. --- docs/errors.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index a327190..9c2452b 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -26,7 +26,8 @@ ClientError (catch-all for anything httpware raises) │ ├── InternalServerError (500) │ └── ServiceUnavailableError (503) ├── RetryBudgetExhaustedError (a retry was needed but the budget refused) -└── BulkheadFullError (acquire_timeout elapsed before a slot opened) +├── BulkheadFullError (acquire_timeout elapsed before a slot opened) +└── DecodeError (response_model= decoder failed; HTTP call itself succeeded) ``` ## Status-to-exception mapping @@ -128,6 +129,32 @@ except RetryBudgetExhaustedError as exc: ) ``` +## `DecodeError` + +`DecodeError` is raised when `response_model=` is set on a request and the active `ResponseDecoder` failed to parse the response body. The HTTP call itself succeeded — status was 2xx/3xx and the transport delivered the body intact — but the body could not be coerced into the requested model. The exception is raised independently of which decoder is in use (`PydanticDecoder`, `MsgspecDecoder`, or a third-party adapter), so `except httpware.ClientError` is sufficient to cover the response-model decode path. + +Fields: + +- `response: httpx2.Response` — the response whose body failed to decode. Status, headers, and the originating `request` are all available via `exc.response.*`. +- `model: type` — the type that was passed as `response_model=`. +- `original: BaseException` — the underlying library exception (e.g., `pydantic.ValidationError`, `msgspec.ValidationError`, `msgspec.DecodeError`). Also available via `exc.__cause__`. + +```python +from httpware import AsyncClient, DecodeError + + +try: + user = await client.get("/users/1", response_model=User) +except DecodeError as exc: + _LOGGER.error( + "decode failed for %s into %s: %s", + exc.response.request.url, + exc.model.__name__, + exc.original, + ) + raise +``` + ## See also - **[Resilience reference](resilience.md)** — `AsyncRetry`, `RetryBudget`, `AsyncBulkhead` parameter tables. From 2ded92d02f15cff72ddfea805b1bca82e1ec6145 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 00:21:00 +0300 Subject: [PATCH 09/10] engineering: document DecodeError + seam B wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the Seam B contract to spell out the wrap, and adds a paragraph to the §4 exception contract describing when DecodeError is raised and what fields it carries. --- planning/engineering.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/planning/engineering.md b/planning/engineering.md index 6aeedfb..34d47bb 100644 --- a/planning/engineering.md +++ b/planning/engineering.md @@ -36,10 +36,10 @@ The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) h - **Contract:** the middleware chain is composed once at client construction and frozen for the client's lifetime. Both worlds follow the same contract; the only difference is the per-world type: `AsyncClient` composes `AsyncMiddleware` via `compose_async` (the continuation type is `AsyncNext`), and `Client` composes `Middleware` via `compose` (the continuation type is `Next`). Both `compose` and `compose_async` live in `src/httpware/middleware/chain.py`. The chain bottom (the "terminal") is internal: it calls `self._httpx2_client.send(request)`, maps `httpx2` errors to `httpware` errors, and raises a `StatusError` subclass on 4xx/5xx. Same lifecycle rules in both worlds. - **Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites. -### Seam B: `AsyncClient ↔ ResponseDecoder` +### Seam B: `Client`/`AsyncClient` ↔ `ResponseDecoder` - **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. -- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Decoder errors (`pydantic.ValidationError`, `msgspec.ValidationError`) propagate unwrapped. +- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Any exception raised by `decode` is wrapped by `Client.send` / `AsyncClient.send` into `httpware.DecodeError` (a `ClientError` subclass carrying `response`, `model`, `original`). Decoder implementers do not need to raise `DecodeError` directly. - **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` itself memoized via `@functools.lru_cache(maxsize=1024)` on a module-level `_get_adapter(model)` factory (the adapter is the expensive part to build). The msgspec adapter implements it as `msgspec.json.decode(content, type=model)`. ### Seam C: `httpware ↔ optional extras` @@ -65,6 +65,8 @@ The error-mapping table (what `httpx2` exception maps to which `httpware` except `TimeoutError` inherits from both `httpware.ClientError` and `builtins.TimeoutError` so `except builtins.TimeoutError` (the form `asyncio.wait_for` uses) also catches httpware-raised timeouts. +`DecodeError` covers the case where `response_model=` is set, the HTTP call itself succeeded, but the active `ResponseDecoder` raised. The wrap happens at the seam in `Client.send` / `AsyncClient.send` — `except Exception` translates any decoder-side failure into `DecodeError(response=..., model=..., original=...)` with `raise ... from exc` chaining. The `original` attribute exposes the underlying library exception (e.g., `pydantic.ValidationError`, `msgspec.ValidationError`); `__cause__` carries the same reference. + ## 5. Module layout Current tree: From ef7a5cf45378130692344c0a94c134a7b3e05ed8 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 00:21:47 +0300 Subject: [PATCH 10/10] docs: note DecodeError in the README response_model paragraph One-line addition: response_model= decode failures raise httpware.DecodeError (a ClientError subclass), so the standard except httpware.ClientError catches them. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3dec825..5fe9bfa 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ with Client(base_url="https://example.test") as client: print(response.json()) ``` -Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`: +Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors. ```python from httpware import AsyncClient