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
32 changes: 16 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,32 @@ Guidance for AI agents (Claude Code, etc.) working in this repository.

`httpware` is a thin, opinionated wrapper around `httpx2` with sync and async clients for building
resilient service clients; [`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the
vocabulary — read it before naming a concept in code, a test name, or an issue title. It ships
vocabulary. Read it before naming a concept in code, a test name, or an issue title. It ships
under the `modern-python` org.

## Commands

`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth —
`just --list`, or read it. The one thing it does not say: `just docs-build` runs `mkdocs --strict`
`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth:
run `just --list`, or read it. The one thing it does not say: `just docs-build` runs `mkdocs --strict`
over the site only, and `docs/adr/` and `docs/agents/` are excluded from it (`exclude_docs` in
`mkdocs.yml`) because they are read on GitHub rather than published. Their links are covered
instead by the `links` job in `.github/workflows/_checks.yml`, which runs lychee `--offline` over
every `*.md` in the repo.

## Architecture

`httpx2` is public surface, not an abstraction to hide: `httpx2.Request` and `httpx2.Response` are
re-exported as-is. Three protocol seams — client ↔ middleware chain, client ↔ decoder list, and
httpware ↔ optional extras — are defined in `CONTEXT.md` and named **A**, **B** and **C** in the
`httpx2` is part of the public surface: `httpx2.Request` and `httpx2.Response` are
re-exported as-is. Three protocol seams (client ↔ middleware chain, client ↔ decoder list, and
httpware ↔ optional extras) are defined in `CONTEXT.md` and named **A**, **B** and **C** in the
module docstrings that implement them. Never cross a seam except through its protocol.

### Key files

Every module under `src/httpware/` is named for what it does; read it. What a single-file read will
**not** tell you:

- `client.py` holds **both worlds** — `Client` and `AsyncClient` — and they are hand-maintained at
parity, not generated. A feature added to one must be mirrored to the other;
- `client.py` holds **both worlds**, `Client` and `AsyncClient`, hand-maintained at parity;
nothing generates one from the other. A feature added to one must be mirrored to the other;
`tests/test_client_parity.py` says why, and names the sole exception (`AsyncTimeout` has no sync
sibling).
- `errors.py` owns the tree and both construction rules. Status-keyed `StatusError` subclasses take
Expand All @@ -54,15 +54,15 @@ Every module under `src/httpware/` is named for what it does; read it. What a si
`RetryBudget`, `_backoff`, `_event_loop_guard`) written once and driven by both worlds. New
resilience logic goes in a shared object, not into each world's shell.

**House invariants, review-only** — nothing in CI catches these: no `httpx2._` private API (ruff
**House invariants, review-only.** Nothing in CI catches these: no `httpx2._` private API (ruff
`SLF001` flags private *attribute* access but not a used private *import*); no
`from __future__ import annotations` (3.11+ floor); no global logging config —
`logging.getLogger("httpware")` and namespaced children only.
`from __future__ import annotations` (3.11+ floor); no global logging config
(`logging.getLogger("httpware")` and namespaced children only).

### Testing patterns

Transport mocking is `httpx2.MockTransport` passed as `transport=`, never `respx` — `respx`
targets `httpx`, not `httpx2`, and patches its internals. Concurrency-sensitive components carry
Transport mocking is `httpx2.MockTransport` passed as `transport=`, never `respx`, which
targets `httpx` rather than `httpx2` and patches its internals. Concurrency-sensitive components carry
Hypothesis property tests in `test_*_props.py`, and `stress`-marked tests drive real thread
parallelism: they run under the GIL for coverage, but the proof comes from the free-threaded
`3.14t` CI job.
Expand All @@ -78,10 +78,10 @@ description, and PyPI does not rewrite relative links, so a relative one 404s on
- Design principle: thin wrapper, small public surface. `httpware.__all__` is checked against an
explicit expected set in `tests/test_public_api.py`, so a symbol cannot appear unnoticed
- `Http` is two letters in a class name (`AsyncClient`, not `ASYNCClient`); no `a` prefix on async
methods, matching `httpx2` — `aclose()` is the sole exception
methods, matching `httpx2`; `aclose()` is the sole exception
- Docstrings: public API documents the contract; internal helpers get a one-line contract, plus at
most 1–2 lines for a genuinely non-obvious constraint. Never narrate implementation or justify
code to a reviewer — cross-file rationale lives in an invariant test's docstring
most one or two lines for a non-obvious constraint. Never narrate implementation or justify
code to a reviewer; cross-file rationale lives in an invariant test's docstring

## Agent skills

Expand Down
20 changes: 10 additions & 10 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# httpware

A thin, opinionated wrapper around `httpx2`: it re-exports `httpx2.Request` and
`httpx2.Response` as the public request/response surface and adds three things —
`httpx2.Response` as the public request/response surface and adds three things:
a middleware chain composed at client construction, typed response decoding, and
a status-keyed exception tree raised automatically on 4xx and 5xx. `Client` and
`AsyncClient` carry the same features.
Expand All @@ -10,14 +10,14 @@ a status-keyed exception tree raised automatically on 4xx and 5xx. `Client` and

A term is listed only when there is a synonym to reject, or a meaning subtle enough that code and
docs must agree on it. General HTTP and programming vocabulary does not belong here, however
heavily this project uses it. `httpx2`'s own names — `Request`, `Response`, `Timeout`, `Limits`,
`Auth`, `MockTransport` — keep `httpx2`'s meanings; nothing here redefines one.
heavily this project uses it. `httpx2`'s own names (`Request`, `Response`, `Timeout`, `Limits`,
`Auth`, `MockTransport`) keep `httpx2`'s meanings; nothing here redefines one.

**Middleware**:
An object satisfying `Middleware` / `AsyncMiddleware`: it takes a request and a continuation
(`Next` / `AsyncNext`) and returns a response. The chain is composed once at client construction
and frozen for the client's lifetime.
_Avoid_: hook — reserved for `httpx2.event_hooks`, a genuinely different mechanism that runs
_Avoid_: hook, which is reserved for `httpx2.event_hooks`, a different mechanism that runs
*below* the chain, once per transport attempt rather than once per logical call.

**Terminal**:
Expand All @@ -37,28 +37,28 @@ optional extras. A seam is not an extension point: an extension point is what a
across. The letters are cited from module docstrings, so they must not be renumbered casually.

**Decoder**:
An object satisfying `ResponseDecoder` — `can_decode(model)` to claim a model, `decode(content,
An object satisfying `ResponseDecoder`: `can_decode(model)` to claim a model, `decode(content,
model)` to turn raw bytes into it in a single parse pass.
_Avoid_: adapter, which names the third-party object a decoder builds and memoizes per model
(`pydantic.TypeAdapter`, `msgspec.json.Decoder`). Calling the decoder an adapter too leaves
"the adapter cache" ambiguous about which of the two it caches.

**Counted failure**:
The circuit breaker's unit of account: a `NetworkError`, an httpware `TimeoutError`, or a
`StatusError` whose status is in the effective failure set (default: all 5xx). Every other 4xx —
429 included — counts as a *success*. A `RetryBudgetExhaustedError` is classified by its
`StatusError` whose status is in the effective failure set (default: all 5xx). Every other 4xx,
429 included, counts as a *success*. A `RetryBudgetExhaustedError` is classified by its
`last_exception`, and any other exception type propagates without touching circuit state. "Failure"
alone is ambiguous here: a failed request is very often not a counted failure.

**Circuit key**:
The value a keyed circuit breaker computes from a request to pick its circuit; requests with equal
keys share one circuit. The default is the URL's origin: scheme, host and port.
_Avoid_: host — the host alone merges upstreams that differ only in scheme or port.
_Avoid_: host. The host alone merges upstreams that differ only in scheme or port.

**Cap**:
`max_response_body_bytes` — the bound on how many bytes httpware buffers on the caller's behalf.
`max_response_body_bytes`: the bound on how many bytes httpware buffers on the caller's behalf.
Counted *decoded*, status-agnostic, and never applied to user-driven `stream()` iteration.
_Avoid_: limit — `limits=` on a client is `httpx2.Limits`, the connection-pool configuration, and
_Avoid_: limit. `limits=` on a client is `httpx2.Limits`, the connection-pool configuration, and
has nothing to do with body size. The one sanctioned exception is public and cannot be renamed
without a breaking change: `ResponseTooLargeError.limit` carries the cap that was exceeded. Prose
says cap; that field says `limit`.
Expand Down
Loading