diff --git a/CLAUDE.md b/CLAUDE.md index a8ee165..bcf8c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,11 +45,11 @@ uv run ruff format . && uv run ruff check . --fix && uv run ty check uv run pytest ``` -## Architecture invariants (CI-enforced) +## Architecture invariants -These are non-negotiable. CI rejects PRs that violate them. +These are non-negotiable, but **most are NOT machine-checked — don't rely on CI to catch a violation.** Enforced by ruff: `print()` (`T201`) and a blanket `# type: ignore` (`PGH003`). Partially: `httpx2._` (ruff `SLF001` catches attribute access, not a *used* private import). Review-only: the future-import and global-logging bans. -- **No `httpx2` private API**: `grep -rE 'httpx2\._' src/httpware/` must return zero matches. Public symbols only. +- **No `httpx2` private API**: `grep -rE 'httpx2\._' src/httpware/` should return zero matches (run in review — not wired into CI). Public symbols only. - **No `from __future__ import annotations`**: Python 3.11+ floor; PEP 604/585 syntax is native. - **No `print()`**: enforced by ruff. - **No global logging config**: no `logging.basicConfig()`, no bare `logging.getLogger()`. Acquire `logging.getLogger("httpware")` or `logging.getLogger(f"httpware.{module}")` only. diff --git a/README.md b/README.md index ae2667e..10d3276 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ **A Python HTTP client framework with sync and async clients for building resilient service clients.** -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a resilience suite under `httpware.middleware.resilience` — `AsyncRetry`/`Retry` with a Finagle-style `RetryBudget`, `AsyncBulkhead`/`Bulkhead` concurrency limiter, `AsyncCircuitBreaker`/`CircuitBreaker` consecutive-failure breaker, and `AsyncTimeout` for overall-operation wall-clock bounds. +`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a resilience suite under `httpware.middleware.resilience` — `AsyncRetry`/`Retry` with a `RetryBudget` (a Finagle-style token bucket that caps the global retry rate to prevent retry storms), `AsyncBulkhead`/`Bulkhead` concurrency limiter, `AsyncCircuitBreaker`/`CircuitBreaker` consecutive-failure breaker, and `AsyncTimeout` for overall-operation wall-clock bounds. > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. @@ -28,7 +28,7 @@ pip install httpware[pydantic,msgspec] # both extras — both decoders registe pip install httpware[all] # everything declared above (pydantic, msgspec, otel) ``` -`AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. `AsyncClient()` never raises on missing extras — failure is deferred to the first `response_model=` call, where `MissingDecoderError` fires *before* the HTTP request if no registered decoder claims the model. +`AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. Missing extras never raise at construction. Instead, resolution is deferred to the first `response_model=` call — and if no registered decoder claims the model, `MissingDecoderError` fires *before* the HTTP request goes out. ## Quickstart diff --git a/architecture/overview.md b/architecture/overview.md index 8034c36..722e4b2 100644 --- a/architecture/overview.md +++ b/architecture/overview.md @@ -4,11 +4,11 @@ `httpx2` is part of the public surface. Exposing `httpx2.Request`/`httpx2.Response` is the design — `httpware` does not own a full abstraction over the underlying HTTP client. -## Architectural invariants (CI-enforced) +## Architectural invariants -These are non-negotiable. CI rejects PRs that violate them. The "why" exists so future contributors can judge edge cases instead of blindly following the rule. +These are non-negotiable, but **enforcement varies — do not assume CI will catch a violation.** Machine-checked: `print()` (ruff `T201`) and a blanket `# type: ignore` (ruff `PGH003`). Partially checked: the `httpx2._` ban — ruff `SLF001` flags private *attribute* access (`httpx2._foo`) but not a *used* private import (`from httpx2._internal import …`). Review-only: the future-import and global-logging bans, and `# type: ignore[]` vs `# ty: ignore[]`. The "why" exists so future contributors can judge edge cases instead of blindly following the rule. -- **No `httpx2._` private API.** *Why:* private symbols can change between patch releases. We accept the public-API surface as the contract. +- **No `httpx2._` private API.** *Why:* private symbols can change between patch releases. We accept the public-API surface as the contract. *Check:* `grep -rE 'httpx2\._' src/httpware/` should return zero matches — run in review; it is not wired into CI. - **No `from __future__ import annotations`.** *Why:* Python 3.11+ floor. PEP 604/585 syntax is native; the future-import would only add noise and inconsistency. - **No `print()`.** *Why:* ruff-enforced. Libraries log; they do not print to stdout. Stray prints leak into consumer applications. - **No global logging config.** *Why:* `logging.basicConfig()` from a library mutates the consumer's logging tree. We only acquire `logging.getLogger("httpware")` or namespaced child loggers and let consumers configure handlers. diff --git a/docs/errors.md b/docs/errors.md index 1f1ed36..bb6d1b5 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -50,9 +50,15 @@ ClientError (catch-all for anything httpware raises) The fallback assumes `400 ≤ status < 600`. Statuses outside that range don't raise (they return the response as-is). +The explicit rows above are also exported as the public `STATUS_TO_EXCEPTION` mapping (`Mapping[int, type[StatusError]]`) — `from httpware import STATUS_TO_EXCEPTION` — so you can look up the class for a status code programmatically (e.g. `STATUS_TO_EXCEPTION.get(404)`). The two fallback rows are not in the mapping; they're applied by the raise logic for any unmapped in-range status. + ## Catching strategies +The examples below assume a module logger in your own namespace (not under `httpware.*`): `_LOGGER = logging.getLogger("myapp")`. + ```python +import logging + from httpware import ( AsyncClient, ClientError, @@ -64,6 +70,8 @@ from httpware import ( BulkheadFullError, ) +_LOGGER = logging.getLogger("myapp") + async def fetch(client: AsyncClient, user_id: int) -> dict | None: try: diff --git a/docs/resilience.md b/docs/resilience.md index 8988a5c..fb8ab63 100644 --- a/docs/resilience.md +++ b/docs/resilience.md @@ -21,7 +21,7 @@ from httpware.middleware.resilience import AsyncRetry | `max_delay` | `5.0` (s) | Ceiling for backoff. | | `retry_status_codes` | `frozenset({408, 429, 502, 503, 504})` | Status codes considered retryable. | | `retry_methods` | `frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})` | Idempotent methods only by default. POST excluded; pass an explicit frozenset including `"POST"` to retry it. | -| `respect_retry_after` | `True` | When the response carries a `Retry-After` header on a retryable status, sleep for the header value instead of the jittered backoff. If the header value exceeds `max_delay`, AsyncRetry gives up and re-raises the underlying `StatusError` with a PEP 678 note `httpware: Retry-After (Ns) exceeded max_delay (Ms); giving up`. Set `max_delay` higher (or `respect_retry_after=False`) to opt out. | +| `respect_retry_after` | `True` | When a retryable response carries a `Retry-After` header, sleep for that value instead of the jittered backoff. If it exceeds `max_delay`, AsyncRetry gives up and re-raises the underlying `StatusError`, attaching an exception note (PEP 678): `httpware: Retry-After (Ns) exceeded max_delay (Ms); giving up`. Opt out with `respect_retry_after=False` or a higher `max_delay`. | | `budget` | `RetryBudget()` (default-configured) | The token bucket. Pass a shared `RetryBudget` instance to apply one budget across multiple clients. | For a whole-operation wall-clock bound across all retry attempts, compose `AsyncTimeout` outermost — see [AsyncTimeout](#asynctimeout) below. For a per-request bound, use `httpx2.Timeout` on the client or pass `timeout=` per request. diff --git a/planning/README.md b/planning/README.md index ea5781e..e40d49a 100644 --- a/planning/README.md +++ b/planning/README.md @@ -70,6 +70,7 @@ carry **no** frontmatter — living prose, dated by git. ### Active +- **[docs-audit-followups](changes/active/2026-06-13.05-docs-audit-followups/change.md)** (draft, 2026-06-13) — Second batch from the [docs audit](audits/2026-06-13-docs-audit.md): fix the overstated invariant-enforcement claims in `CLAUDE.md` + `architecture/overview.md` (only `print()`/blanket-`type: ignore` are machine-checked), plus readability findings R1–R3 and documenting the public `STATUS_TO_EXCEPTION` (G5). - **[docs-accuracy-fixes](changes/active/2026-06-13.04-docs-accuracy-fixes/change.md)** (draft, 2026-06-13) — Fix the 5 verified factual errors from the [docs audit](audits/2026-06-13-docs-audit.md): RetryBudget formula, modern-di 2.x recipe, contributing-doc CI/grep claim, `just lint` comment, middleware stable-contracts list (+ AsyncTimeout non-finite wording). ### Archived (shipped) diff --git a/planning/audits/2026-06-13-docs-audit.md b/planning/audits/2026-06-13-docs-audit.md index ff977a0..a86505d 100644 --- a/planning/audits/2026-06-13-docs-audit.md +++ b/planning/audits/2026-06-13-docs-audit.md @@ -101,15 +101,20 @@ batch.) **R1 — dense stacked-qualifier sentences in the hottest spots.** `README.md:31` (decoder resolution) stacks two qualified clauses; the `respect_retry_after` cell in `docs/resilience.md:24` is a four-sentence paragraph inside a table cell. -Correct, but hard on first read. *Suggest:* split the densest sentences/cells. +Correct, but hard on first read. **Resolved** (`2026-06-13.05`) — split the +decoder sentence and tightened the table cell. **R2 — unglossed jargon on first use.** "Finagle-style" (`README.md:17`), "full-jitter", "bulkhead", "PEP 678 note", "token bucket" — most are defined -later or never, but the README is first contact. *Suggest:* a one-clause gloss on -first use. +later or never, but the README is first contact. **Resolved** (`2026-06-13.05`) +— glossed "Finagle-style `RetryBudget`" (token bucket capping the global retry +rate) and "PEP 678 note" → "an exception note (PEP 678)". ("bulkhead"/"full-jitter" +left as standard resilience vocabulary.) **R3 — `_LOGGER` used in `errors.md` examples (lines 77, 130, 154) without -definition.** A literal copy-paste hits `NameError`. Minor/conventional. +definition.** A literal copy-paste hits `NameError`. **Resolved** (`2026-06-13.05`) +— added `import logging` + `_LOGGER = logging.getLogger("myapp")` to the first +block and a one-line note that the examples assume it. ### Onboarding & UX gaps (the larger lane — not bugs) @@ -133,8 +138,9 @@ accompanying change. real public test endpoint for the leading example. - **G5 — `STATUS_TO_EXCEPTION` is a public `__all__` export (`src/httpware/__init__.py:54`) documented nowhere.** The lone undocumented - public symbol. *Suggest:* document it (it is the extensible status→exception - map) or reconsider its place in `__all__`. + public symbol. **Resolved** (`2026-06-13.05`) — documented at the + status-to-exception table in `docs/errors.md` (public `Mapping[int, + type[StatusError]]`, importable, fallback rows excluded). - **G6 — No custom-`ResponseDecoder` guide and no API reference.** The decoder seam (Seam B) is a documented extension point but, unlike middleware, gets no "write your own" guide; and there is no generated symbol reference @@ -181,14 +187,23 @@ follow them. No orphan pages and no broken nav targets — the nav is otherwise - **`2026-06-13.04-docs-accuracy-fixes`** (lightweight) — fixes B1, B2, I1, I2, I3, and the `AsyncTimeout`-validation wording. All verified against code / official upstream docs. +- **`2026-06-13.05-docs-audit-followups`** (lightweight) — the invariant-enforcement + wording fix (triage item below) plus readability/small-gap findings R1, R2, R3, G5. ## Deferred / triage -- The onboarding & UX gaps (G1–G6) — a separate, larger docs-UX change (de-dup, - why-httpware, base-client migration, runnable quickstart, custom-decoder guide, - API reference). Not yet scheduled. -- The `httpx2._` invariant is documented as CI-enforced (`CLAUDE.md`, - `architecture/overview.md`, and — fixed here — `contributing.md`) but no CI - workflow runs the grep. Either wire `grep -rE 'httpx2\._' src/httpware/` into the - lint workflow (tiny CI change) or downgrade the "CI-enforced" wording in the two - internal truth docs. Parked pending a decision on which. +- The onboarding & UX gaps **G1, G2, G3, G4, G6** (why-httpware, base-client + migration, README ↔ index de-dup, runnable quickstart, custom-decoder guide + + API reference) — a separate, larger docs-UX change that needs design, not + mechanical edits. Not yet scheduled. (G5 resolved in `2026-06-13.05`.) +- ~~The `httpx2._` invariant is documented as CI-enforced but no CI workflow runs + the grep.~~ **Resolved (option 2 — fix the claim).** Empirically confirmed against + the real `ruff --select ALL` ruleset: only `print()` (`T201`) and a *blanket* + `# type: ignore` (`PGH003`) are machine-checked; the `httpx2._` ban is partial + (`SLF001` catches private *attribute* access, e.g. `httpx2._foo`, but **not** a + *used* private import like `from httpx2._internal import x`); the future-import, + global-logging, and `# ty:`-vs-`# type:` rules are review-only. The blanket + "(CI-enforced) / CI rejects PRs" heading in `CLAUDE.md` and + `architecture/overview.md` was rewritten to state the actual enforcement split + and to note the `httpx2._` grep is a review check, not a CI gate. + `contributing.md` was already corrected in the `docs-accuracy-fixes` change. diff --git a/planning/changes/active/2026-06-13.05-docs-audit-followups/change.md b/planning/changes/active/2026-06-13.05-docs-audit-followups/change.md new file mode 100644 index 0000000..e3900cf --- /dev/null +++ b/planning/changes/active/2026-06-13.05-docs-audit-followups/change.md @@ -0,0 +1,71 @@ +--- +status: draft +date: 2026-06-13 +slug: docs-audit-followups +supersedes: null +superseded_by: null +pr: null +outcome: null +--- + +# Change: Docs-audit follow-ups — invariant-enforcement wording + readability + +**Lane:** lightweight — docs-only, no code, no public-API change. Touches a +handful of doc/truth files (above the usual ≤2 guard, but the guard proxies +*code* risk; these are mechanical/verified corrections whose thinking lives in +the audit). Spec is the audit, not a `design.md`. + +Spec: [`planning/audits/2026-06-13-docs-audit.md`](../../../audits/2026-06-13-docs-audit.md) +— the second batch: the resolved `httpx2._` triage item plus findings R1, R2, R3, G5. +(First batch — the verified bugs B1/B2/I1/I2/I3 — shipped in +[`2026-06-13.04-docs-accuracy-fixes`](../2026-06-13.04-docs-accuracy-fixes/change.md).) + +## Goal + +Make the invariant-enforcement claims accurate and clear the concrete +readability/small-gap findings. No structural docs-UX work (de-dup, why-httpware, +migration guide, API reference) — that stays a separate, design-led change. + +## Approach + +- **Invariant enforcement (triage item, option 2 — "fix the claim").** Empirically + confirmed against `ruff --select ALL`: only `print()` (`T201`) and a blanket + `# type: ignore` (`PGH003`) are machine-checked; `httpx2._` is partial (`SLF001` + catches attribute access, not a *used* private import); future-import / logging / + `# ty:`-vs-`# type:` are review-only. Rewrote the overstated "(CI-enforced) / CI + rejects PRs" heading + intro in `CLAUDE.md` and `architecture/overview.md` to the + real split, and reframed the `httpx2._` grep as a review check (not a CI gate). +- **R3** `docs/errors.md` — examples used `_LOGGER` undefined (copy-paste `NameError`). + Added `import logging` + `_LOGGER = logging.getLogger("myapp")` to the first block + and a one-line note that the examples assume it. +- **G5** `docs/errors.md` — documented the public `STATUS_TO_EXCEPTION` mapping at the + status-to-exception table (it was the lone undocumented `__all__` export). +- **R2** `README.md` — glossed "Finagle-style `RetryBudget`" as a token bucket that + caps the global retry rate, on first use. +- **R1** `README.md` + `docs/resilience.md` — de-densified the decoder-resolution + sentence and tightened the run-on `respect_retry_after` table cell; glossed + "PEP 678 note" → "an exception note (PEP 678)". + +## Files + +- `CLAUDE.md` — invariant-enforcement heading/intro + `httpx2._` bullet +- `architecture/overview.md` — same, truth-home copy +- `docs/errors.md` — R3 (`_LOGGER`) + G5 (`STATUS_TO_EXCEPTION`) +- `README.md` — R2 (Finagle gloss) + R1 (decoder sentence) +- `docs/resilience.md` — R1 (`respect_retry_after` cell) +- `planning/audits/2026-06-13-docs-audit.md` — mark items resolved + +## Verification + +- [x] `mkdocs build --strict` succeeds (no broken refs). +- [x] `just lint` — clean (no source touched). +- [x] Enforcement claims match the empirical `ruff --select ALL` result + (`T201`/`PGH003` fire; future-import, `basicConfig`, bare `getLogger`, + and `from httpx2._x import …` do not). + +## Deferred (still open after this change) + +The structural docs-UX gaps need design, not mechanical edits: **G1** why-httpware, +**G2** base-client migration guide, **G3** de-dup README ↔ index.md, **G4** runnable +first quickstart (real endpoint), **G6** custom-`ResponseDecoder` guide + mkdocstrings +API reference, plus the nav-ordering nits. Tracked in the audit's onboarding/UX section.