You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 4a964a9
Browse filesBrowse the repository at this point in the historyBrowse files
* docs(spec): adopt portable two-axis planning convention
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plan): portable planning convention migration plan
Also remove the bare plan.md gitignore rule, incompatible with the
per-bundle plan.md the convention introduces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(planning): bootstrap convention skeleton (templates, deferred.md rename)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(architecture): split engineering.md into per-capability truth files
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(architecture): fix stale resilience-suite enumerations and terminal/decoder wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): migrate 0.1.0-0.7.0 specs/plans into change bundles
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): migrate 0.8.0-0.10.0 specs/plans into change bundles
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(planning): normalize trailing newlines on migrated flat-cohort bundles
The pre-migration planning/specs and planning/plans files lacked trailing
newlines; eof-fixer . --check (run by just lint-ci) flags them. Normalized
to match the rest of the tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): move audit reports to audits/, tooling to audits/scripts/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): add README with portable Conventions + repo Index
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): restore blank line before README Index heading
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: repoint inbound links to architecture/ and changes/ bundles
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): repoint two stray bare engineering.md refs
deferred.md had a now-broken markdown link to engineering.md §8 (roadmap,
dissolved); 0.6.0 release note had a bare engineering.md §2 prose ref. Both
missed in the link-repoint pass because they lacked the planning/ prefix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CLAUDE.md
+11-9Lines changed: 11 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,14 +8,16 @@ Guidance for AI agents (Claude Code, etc.) working in this repository.
8
8
9
9
**Where to find what:**
10
10
11
-
-[`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.
12
-
-[`planning/deferred-work.md`](planning/deferred-work.md) — review-surfaced items that are real but not actionable now.
13
-
-[`planning/specs/`](planning/specs/) and [`planning/plans/`](planning/plans/) — per-feature design specs and implementation plans (active work).
14
-
-[`planning/archive/specs/`](planning/archive/specs/) and [`planning/archive/plans/`](planning/archive/plans/) — shipped or superseded work, kept for historical context.
15
-
-[`planning/retros/`](planning/retros/) — release- and epic-level retrospectives.
11
+
-[`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.**
12
+
-[`planning/README.md`](planning/README.md) — the planning conventions (two axes, change bundles, three lanes, frontmatter) + the change Index.
13
+
-[`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).
**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.
20
+
**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.
19
21
20
22
## Commands
21
23
@@ -61,7 +63,7 @@ These are non-negotiable. CI rejects PRs that violate them.
61
63
-**Private symbols**: `_leading_underscore`. Cross-module private code lives in `_internal/`.
62
64
-**Imports**: absolute paths inside `src/httpware/`; relative imports only within the same subpackage.
63
65
-**Docstrings**: PEP 257. Module/class/public-method required; `D1` (missing docstring) is ignored.
64
-
-**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.
66
+
-**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`.
65
67
66
68
## Module layout
67
69
@@ -81,7 +83,7 @@ src/httpware/
81
83
Three documented internal boundaries. AI agents must respect them — never cross a seam except through its documented protocol.
82
84
83
85
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.
84
-
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.
86
+
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).
85
87
3.**Seam C** — `httpware` ↔ optional extras — each opt-in dependency imported only inside its dedicated module.
86
88
87
89
## Testing
@@ -92,5 +94,5 @@ Three documented internal boundaries. AI agents must respect them — never cros
92
94
93
95
## When in doubt
94
96
95
-
- Check [`planning/engineering.md`](planning/engineering.md) before adding a new module or extension point.
97
+
- Check the relevant [`architecture/`](architecture/) capability file before adding a new module or extension point.
96
98
- Surface ambiguity as a documentation gap rather than improvising.
`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()`.
4
+
5
+
## The internal terminal
6
+
7
+
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`.
8
+
9
+
## Sync/async parity
10
+
11
+
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`.
12
+
13
+
The async middleware surface uses the `Async*`/`async_*` prefix, aligning with httpx2's convention.
14
+
15
+
## Streaming
16
+
17
+
`AsyncClient.stream()` provides a context-manager API for chunked response bodies. It bypasses the middleware chain by design.
A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.
4
+
5
+
## Seam B: `Client`/`AsyncClient` ↔ `ResponseDecoder` list
6
+
7
+
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.
-**Contract:** the client holds `_decoders: tuple[ResponseDecoder, ...]` composed at `__init__` and frozen for the client's lifetime. The Protocol exposes two methods:
11
+
-`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.
12
+
-`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.
13
+
-**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=[...]`).
14
+
-**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.
15
+
-**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.
`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.).
4
+
5
+
```python
6
+
raise NotFoundError(response) # correct
7
+
exc.response.status_code # 404
8
+
exc.response.request.url # URL of the failed request
9
+
```
10
+
11
+
`__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.
12
+
13
+
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`.
14
+
15
+
`TimeoutError` inherits from both `httpware.ClientError` and `builtins.TimeoutError` so `except builtins.TimeoutError` (the form `asyncio.wait_for` uses) also catches httpware-raised timeouts.
16
+
17
+
`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.
18
+
19
+
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.
A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.
4
+
5
+
## Seam C: `httpware` ↔ optional extras
6
+
7
+
-**Where:**`pyproject.toml` extras (`[project.optional-dependencies]`) ↔ the adapter modules that import them.
8
+
-**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.
9
+
-**Rule:** never import an extra at package top-level. The package must import cleanly when the extra is not installed.
10
+
-**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.
11
+
12
+
## The optional-extras pattern
13
+
14
+
`httpware` core has a small dependency set. Capabilities that pull in heavyweight dependencies (`pydantic`, `msgspec`) live behind extras declared in `pyproject.toml`:
15
+
16
+
```toml
17
+
[project.optional-dependencies]
18
+
pydantic = ["pydantic>=2"]
19
+
msgspec = ["msgspec>=0.18"]
20
+
```
21
+
22
+
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`.
23
+
24
+
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(...)`.
25
+
26
+
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.
-**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.
9
+
-**Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites.
10
+
11
+
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.
12
+
13
+
## Why there is no standalone OpenTelemetry middleware
14
+
15
+
`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).
0 commit comments