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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<code>]` vs `# ty: ignore[<code>]`. 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.
Expand Down
8 changes: 8 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -64,6 +70,8 @@ from httpware import (
BulkheadFullError,
)

_LOGGER = logging.getLogger("myapp")


async def fetch(client: AsyncClient, user_id: int) -> dict | None:
try:
Expand Down
2 changes: 1 addition & 1 deletion docs/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 29 additions & 14 deletions planning/audits/2026-06-13-docs-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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.