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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,4 @@ wheels/
.python-version
.venv
uv.lock
plan.md
site/
20 changes: 11 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ Guidance for AI agents (Claude Code, etc.) working in this repository.

**Where to find what:**

- [`planning/engineering.md`](planning/engineering.md) — the distilled design reference: invariants and *why*, the three protocol seams, exception contract, module layout, testing patterns, optional-extras pattern, remaining roadmap. Read this before adding any new module or extension point.
- [`planning/deferred-work.md`](planning/deferred-work.md) — review-surfaced items that are real but not actionable now.
- [`planning/specs/`](planning/specs/) and [`planning/plans/`](planning/plans/) — per-feature design specs and implementation plans (active work).
- [`planning/archive/specs/`](planning/archive/specs/) and [`planning/archive/plans/`](planning/archive/plans/) — shipped or superseded work, kept for historical context.
- [`planning/retros/`](planning/retros/) — release- and epic-level retrospectives.
- [`architecture/`](architecture/) (repo root) — the per-capability living truth (overview, client, middleware, decoders, errors, resilience, optional extras, testing); the promotion target on every ship. **Read the relevant file before changing that capability.**
- [`planning/README.md`](planning/README.md) — the planning conventions (two axes, change bundles, three lanes, frontmatter) + the change Index.
- [`planning/changes/{active,archive}/<YYYY-MM-DD.NN-slug>/`](planning/changes/) — per-change bundles (`design.md` + `plan.md`, or `change.md` for the lightweight lane).
- [`planning/audits/`](planning/audits/) — findings reports + `scripts/` tooling.
- [`planning/retros/`](planning/retros/) — retrospectives.
- [`planning/releases/`](planning/releases/) — per-version release notes (also published on GitHub Releases).
- [`planning/deferred.md`](planning/deferred.md) — review-surfaced, not-yet-actionable items.
- [`planning/_templates/`](planning/_templates/) — design/plan/change templates.

**Per-feature workflow:** brainstorming → spec in `planning/specs/` → writing-plans → plan in `planning/plans/` → executing-plans (or subagent-driven-development) → requesting-code-review → finishing-a-development-branch. Topic slugs are kebab-case descriptions (`msgspec-decoder-adapter`), not story IDs.
**Per-feature workflow:** brainstorming → `design.md` in `planning/changes/active/<id>/` → writing-plans → `plan.md` in the same bundle → executing-plans (or subagent-driven-development) → requesting-code-review → finishing-a-development-branch. On ship, promote the conclusions into the affected `architecture/<capability>.md` by hand and move the bundle to `planning/changes/archive/`. Topic slugs are kebab-case descriptions (`msgspec-decoder-adapter`), not story IDs.

## Commands

Expand Down Expand Up @@ -61,7 +63,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 `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.
- **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`, `RetryBudgetExhaustedError`, and `CircuitOpenError` deliberately define `__init__` with keyword-only fields. See `architecture/errors.md`.

## Module layout

Expand All @@ -81,7 +83,7 @@ src/httpware/
Three documented internal boundaries. AI agents must respect them — never cross a seam except through its documented protocol.

1. **Seam A** — `Client`/`AsyncClient` ↔ `Middleware`/`AsyncMiddleware` — middleware chain composed at `Client.__init__` and `AsyncClient.__init__`, frozen for the client's lifetime. Internal terminal calls `httpx2.Client.send` or `httpx2.AsyncClient.send`, maps exceptions, raises `StatusError` on 4xx/5xx. Sync and async surfaces are kept at parity.
2. **Seam B** — `Client`/`AsyncClient` ↔ `ResponseDecoder` list — both clients take `decoders: Sequence[ResponseDecoder] | None` (a *list*, not a single decoder; `None` resolves against installed extras, pydantic-first). When `response_model` is provided, `send`/`send_with_response` (sync and async alike) walk the list and the first decoder whose `can_decode(model: type) -> bool` returns True runs `decode(content: bytes, model: type[T]) -> T`; if no decoder claims the model, `MissingDecoderError` is raised *before* the HTTP call. Decoder exceptions are wrapped as `DecodeError` at the seam. Full contract: [`engineering.md`](planning/engineering.md) §Seam B.
2. **Seam B** — `Client`/`AsyncClient` ↔ `ResponseDecoder` list — both clients take `decoders: Sequence[ResponseDecoder] | None` (a *list*, not a single decoder; `None` resolves against installed extras, pydantic-first). When `response_model` is provided, `send`/`send_with_response` (sync and async alike) walk the list and the first decoder whose `can_decode(model: type) -> bool` returns True runs `decode(content: bytes, model: type[T]) -> T`; if no decoder claims the model, `MissingDecoderError` is raised *before* the HTTP call. Decoder exceptions are wrapped as `DecodeError` at the seam. Full contract: [`architecture/decoders.md`](architecture/decoders.md).
3. **Seam C** — `httpware` ↔ optional extras — each opt-in dependency imported only inside its dedicated module.

## Testing
Expand All @@ -92,5 +94,5 @@ Three documented internal boundaries. AI agents must respect them — never cros

## When in doubt

- Check [`planning/engineering.md`](planning/engineering.md) before adding a new module or extension point.
- Check the relevant [`architecture/`](architecture/) capability file before adding a new module or extension point.
- Surface ambiguity as a documentation gap rather than improvising.
17 changes: 17 additions & 0 deletions architecture/client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Client

`httpware` ships two clients: a sync `Client` and an async `AsyncClient`, both at the top level. They are thin wrappers over `httpx2.Client` and `httpx2.AsyncClient` respectively. Both carry full feature parity: typed decoding, the middleware chain, the full resilience suite, and `stream()`.

## The internal terminal

The bottom of the middleware chain (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. The error-mapping table (what `httpx2` exception maps to which `httpware` exception) lives at the terminal in `src/httpware/client.py`; status-keyed exceptions are looked up via the `STATUS_TO_EXCEPTION` table in `src/httpware/errors.py`. The same terminal lifecycle holds in both worlds — `Client.send` calls `httpx2.Client.send`, `AsyncClient.send` calls `httpx2.AsyncClient.send`.

## Sync/async parity

The sync and async surfaces are kept at parity. Shared state is thread-safe where it must be: `RetryBudget` is a single class used by both worlds and is thread-safe. Sync `Bulkhead` uses `threading.Semaphore` and cannot share an instance with `AsyncBulkhead`.

The async middleware surface uses the `Async*`/`async_*` prefix, aligning with httpx2's convention.

## Streaming

`AsyncClient.stream()` provides a context-manager API for chunked response bodies. It bypasses the middleware chain by design.
15 changes: 15 additions & 0 deletions architecture/decoders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Decoders

A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.

## Seam B: `Client`/`AsyncClient` ↔ `ResponseDecoder` list

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=[...]`).
- **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.
19 changes: 19 additions & 0 deletions architecture/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Errors

`StatusError` and all its 4xx/5xx subclasses are constructed with a **single positional `response: httpx2.Response`**. Subclasses do not override `__init__`. All fields are available via `exc.response.*` (status code, headers, content, request, etc.).

```python
raise NotFoundError(response) # correct
exc.response.status_code # 404
exc.response.request.url # URL of the failed request
```

`__repr__` and the `str()` summary strip `user:pass@` userinfo from `response.request.url` to avoid leaking credentials in tracebacks. Query-string secrets are not stripped here.

The error-mapping table (what `httpx2` exception maps to which `httpware` exception) lives at the terminal in `src/httpware/client.py`. Status-keyed exceptions are looked up via the `STATUS_TO_EXCEPTION` table in `src/httpware/errors.py`. Unknown 4xx falls back to `ClientStatusError`; unknown 5xx falls back to `ServerStatusError`.

`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.

The "no `__init__` override" rule scopes only to `StatusError` subclasses. Non-status `ClientError` subclasses — `DecodeError`, `MissingDecoderError`, `BulkheadFullError`, `RetryBudgetExhaustedError`, `CircuitOpenError` — deliberately define `__init__` with keyword-only fields.
26 changes: 26 additions & 0 deletions architecture/extras.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Optional extras

A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.

## Seam C: `httpware` ↔ optional extras

- **Where:** `pyproject.toml` extras (`[project.optional-dependencies]`) ↔ the adapter modules that import them.
- **Contract:** each optional dependency is imported only inside its own dedicated module (e.g., `pydantic` in `decoders/pydantic.py`; `msgspec` in `decoders/msgspec.py`). New extras are declared in `pyproject.toml` at the same time the code that uses them lands — not earlier.
- **Rule:** never import an extra at package top-level. The package must import cleanly when the extra is not installed.
- **Verification:** `tests/test_optional_extras_isolation.py` runs a fresh-subprocess `import httpware` and asserts that neither pydantic nor msgspec ends up in `sys.modules`. New extras must add the same isolation test.

## The optional-extras pattern

`httpware` core has a small dependency set. Capabilities that pull in heavyweight dependencies (`pydantic`, `msgspec`) live behind extras declared in `pyproject.toml`:

```toml
[project.optional-dependencies]
pydantic = ["pydantic>=2"]
msgspec = ["msgspec>=0.18"]
```

Each extra's code lives in a single dedicated module (`decoders/pydantic.py`, `decoders/msgspec.py`). The `import` of the extra happens **inside** that module behind an `is_<extra>_installed` guard from `_internal/import_checker.py` — never at package top level. This way, `import httpware` works cleanly without the extras installed, and the seam stays observable: `grep -rnE 'from pydantic|import pydantic' src/httpware/ | grep -v import_checker` returns exactly one indented line (the guarded import in `decoders/pydantic.py`), and the same is true for `msgspec`.

New extras are added at the same time as the code that uses them — never preemptively. The `otel` extra is paired with the code that uses it: `Retry`, `Bulkhead`, `CircuitBreaker`, and `AsyncTimeout` add events to the active OpenTelemetry span via `trace.get_current_span().add_event(...)`.

Caller-facing pattern: `AsyncClient()` / `Client()` default `decoders=None` resolves via `_build_default_decoders()` against installed extras (pydantic-first when both are present, the installed one when only one is present, empty tuple when neither). Consumers override by passing `decoders=[...]` explicitly; `decoders=[]` is honored as an opt-out. The auto-resolution is a snapshot of `import_checker.is_<extra>_installed` flags at `__init__` time — there is no runtime re-detection or implicit registry beyond the two built-in decoders.
15 changes: 15 additions & 0 deletions architecture/middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Middleware

A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.

## Seam A: `Client`/`AsyncClient` ↔ `Middleware`/`AsyncMiddleware`

- **Where:** `src/httpware/client.py` ↔ `src/httpware/middleware/`.
- **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.

Phase decorators (declared alongside `Middleware`/`AsyncMiddleware`, `Next`/`AsyncNext` in `src/httpware/middleware/__init__.py`) let a middleware target a specific phase of the request lifecycle.

## Why there is no standalone OpenTelemetry middleware

`httpware` deliberately does not ship a separate OTel tracing middleware layer. `opentelemetry-instrumentation-httpx` already covers transport-level tracing; a separate httpware middleware would duplicate it. Observability that `httpware` does add lives where it has information httpx2 lacks — the `Retry` and `Bulkhead` span events on the active span (see Resilience).
Loading