Skip to content

Commit 4a964a9

Browse files
lesnik512claude
andauthored
Adopt portable two-axis planning convention (#55)
* 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>
1 parent 7b8124b commit 4a964a9

119 files changed

Lines changed: 1966 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,4 @@ wheels/
2323
.python-version
2424
.venv
2525
uv.lock
26-
plan.md
2726
site/

‎CLAUDE.md‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ Guidance for AI agents (Claude Code, etc.) working in this repository.
88

99
**Where to find what:**
1010

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).
14+
- [`planning/audits/`](planning/audits/) — findings reports + `scripts/` tooling.
15+
- [`planning/retros/`](planning/retros/) — retrospectives.
1616
- [`planning/releases/`](planning/releases/) — per-version release notes (also published on GitHub Releases).
17+
- [`planning/deferred.md`](planning/deferred.md) — review-surfaced, not-yet-actionable items.
18+
- [`planning/_templates/`](planning/_templates/) — design/plan/change templates.
1719

18-
**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.
1921

2022
## Commands
2123

@@ -61,7 +63,7 @@ These are non-negotiable. CI rejects PRs that violate them.
6163
- **Private symbols**: `_leading_underscore`. Cross-module private code lives in `_internal/`.
6264
- **Imports**: absolute paths inside `src/httpware/`; relative imports only within the same subpackage.
6365
- **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`.
6567

6668
## Module layout
6769

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

8385
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).
8587
3. **Seam C** — `httpware` ↔ optional extras — each opt-in dependency imported only inside its dedicated module.
8688

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

9395
## When in doubt
9496

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.
9698
- Surface ambiguity as a documentation gap rather than improvising.

‎architecture/client.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Client
2+
3+
`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.

‎architecture/decoders.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Decoders
2+
3+
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.
8+
9+
- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`.
10+
- **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.

‎architecture/errors.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Errors
2+
3+
`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.

‎architecture/extras.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Optional extras
2+
3+
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.

‎architecture/middleware.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Middleware
2+
3+
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 A: `Client`/`AsyncClient` ↔ `Middleware`/`AsyncMiddleware`
6+
7+
- **Where:** `src/httpware/client.py` ↔ `src/httpware/middleware/`.
8+
- **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

Comments
 (0)