diff --git a/CLAUDE.md b/CLAUDE.md index 7068a2d..400a2bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ These are non-negotiable. CI rejects PRs that violate them. - **Private symbols**: `_leading_underscore`. Cross-module private code lives in `_internal/`. - **Imports**: absolute paths inside `src/httpware/`; relative imports only within the same subpackage. - **Docstrings**: PEP 257. Module/class/public-method required; `D1` (missing docstring) is ignored. -- **Exception construction**: status-keyed errors take a single positional `response: httpx2.Response`. Subclasses do not override `__init__`. All fields available via `exc.response.*`. +- **Exception construction**: status-keyed `StatusError` subclasses (the 4xx/5xx tree) take a single positional `response: httpx2.Response` and do NOT override `__init__` — all fields via `exc.response.*`. This rule scopes to `StatusError` only; non-status `ClientError` subclasses such as `DecodeError`, `MissingDecoderError`, `BulkheadFullError`, and `RetryBudgetExhaustedError` deliberately define `__init__` with keyword-only fields. See `engineering.md` §4. ## Module layout diff --git a/README.md b/README.md index a131a28..c24a8c0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,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]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors. +Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). 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 diff --git a/docs/errors.md b/docs/errors.md index 00729a6..69927b2 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -163,10 +163,15 @@ Raised by `send()` / `send_with_response()` / verb methods when `response_model= - `model: type` — the `response_model=` value that wasn't claimed. - `registered_names: tuple[str, ...]` — class names of the registered decoders that all rejected the model. Empty tuple means no decoders were registered. -Corrective action depends on the message hint: +The message reads `no decoder for response_model=: `, and the corrective action depends on the hint. The two hints, verbatim: -- `no decoders registered. Install pip install httpware[pydantic] or pip install httpware[msgspec], or pass decoders=[...] explicitly.` — install an extra or pass an explicit decoder list. -- `registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it.` — your `response_model` type is exotic enough that neither built-in claims it. Pass a custom `ResponseDecoder` via `decoders=[...]`. +- **No decoders were registered** — install an extra or pass an explicit decoder list: + + no decoders registered. Install `pip install httpware[pydantic]` or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly. + +- **Registered decoders all rejected the model** — your `response_model` type is exotic enough that neither built-in claims it; pass a custom `ResponseDecoder` via `decoders=[...]`: + + registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it. Pass a custom decoder via decoders=[...]. Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic is sent. diff --git a/docs/index.md b/docs/index.md index 952ae79..5ff246e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -73,6 +73,10 @@ decoders claim broadly within their library; the ordering encodes your preference for shared shapes (`dict`, `list[Foo]`, dataclasses, primitives): ```python +from httpware import AsyncClient +from httpware.decoders.msgspec import MsgspecDecoder +from httpware.decoders.pydantic import PydanticDecoder + # pydantic-first (the default when both extras are installed): # - BaseModel -> pydantic # - Struct -> msgspec diff --git a/planning/deferred-work.md b/planning/deferred-work.md index 9ff9bd4..a1de644 100644 --- a/planning/deferred-work.md +++ b/planning/deferred-work.md @@ -6,10 +6,10 @@ As of 0.7.0, all planned epics (3, 4, 5, 6) are closed — see [`engineering.md` ## Open -### Decoder-side - -- **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. No such configurability is on the roadmap, so this item is dormant unless a real use-case surfaces. (`src/httpware/decoders/pydantic.py:12-14`) - ### Client API surface - **Per-verb-with-response siblings** (`get_with_response`, `post_with_response`, `request_with_response`) — the v0.8.2 spec deliberately ships only `send_with_response`; the verb-method shape would add ~400 LOC of overload boilerplate per side for a pattern (response headers + typed body) that's almost always paired with a GET and `build_request`. Revisit if a concrete consumer demand surfaces. (`src/httpware/client.py`) + +## Resolved + +- **Decoder caches are now per-instance** (was: *`_get_adapter` `lru_cache` is module-global*) — PR #42 (0.9.0) replaced the module-level `@functools.lru_cache` with per-instance `_adapters` / `_msgspec_decoders` dicts on each decoder, so configurations no longer share state across instances and caches don't survive across tests. 0.9.1 added a per-instance `can_decode` verdict cache on the same model. No module-global decoder cache remains. diff --git a/planning/engineering.md b/planning/engineering.md index 12e2ad2..c008e4a 100644 --- a/planning/engineering.md +++ b/planning/engineering.md @@ -4,7 +4,7 @@ This doc is the single distilled reference for `httpware` design rationale, prot ## 1. Project intent -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. As of 0.9.0, both clients take `decoders: Sequence[ResponseDecoder] | None = None` (a *list*, not a single instance) and dispatch via each decoder's `can_decode(model)` predicate; the default resolves against installed extras (pydantic-first when both present) and `AsyncClient()` / `Client()` no longer raise on missing extras. A new `MissingDecoderError` (sibling of `DecodeError` under `ClientError`) fires before the HTTP call when `response_model=` is set but no registered decoder claims the model. As of 0.4.0, the package ships a small resilience suite under `httpware.middleware.resilience` — a `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — composed via the standard middleware chain. As of 0.5.0, `AsyncClient.stream()` provides a context-manager API for chunked response bodies; it bypasses the middleware chain by design (see planning/archive/specs/2026-06-05-streaming-design.md). As of 0.6.0, `Retry` and `Bulkhead` emit operational events via stdlib `logging` records (`httpware.retry` / `httpware.bulkhead` loggers) and — when `opentelemetry-api` is installed — OpenTelemetry span events on the active span. As of 0.7.0, the first-cut user-docs surface is live at (Middleware, Resilience, Errors, Testing guides) and Epic 3 is closed. +`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. As of 0.9.0, both clients take `decoders: Sequence[ResponseDecoder] | None = None` (a *list*, not a single instance) and dispatch via each decoder's `can_decode(model)` predicate; the default resolves against installed extras (pydantic-first when both present) and `AsyncClient()` / `Client()` no longer raise on missing extras. A new `MissingDecoderError` (sibling of `DecodeError` under `ClientError`) fires before the HTTP call when `response_model=` is set but no registered decoder claims the model. As of 0.4.0, the package ships a small resilience suite under `httpware.middleware.resilience` — a `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — composed via the standard middleware chain. As of 0.5.0, `AsyncClient.stream()` provides a context-manager API for chunked response bodies; it bypasses the middleware chain by design (see planning/archive/specs/2026-06-05-streaming-design.md). As of 0.6.0, `Retry` and `Bulkhead` emit operational events via stdlib `logging` records (`httpware.retry` / `httpware.bulkhead` loggers) and — when `opentelemetry-api` is installed — OpenTelemetry span events on the active span. As of 0.7.0, the first-cut user-docs surface is live at (Middleware, Resilience, Errors, Testing guides) and Epic 3 is closed. As of 0.8.0 the async middleware surface uses the `Async*`/`async_*` prefix (aligning with httpx2's convention); the `attempt_timeout=` kwarg was removed from `AsyncRetry` in the same release — see `planning/specs/2026-06-07-sync-client-design.md` for the rationale.