Skip to content

Commit f203821

Browse files
lesnik512claude
andcommitted
docs: fix verified accuracy bugs from docs audit
Audit of the user-facing docs surfaced 5 verified factual errors. Fixes: - resilience.md: RetryBudget token-bucket formula used int(deposits*percent); code uses math.ceil (budget.py:68). Under-counted permitted retries. - recipes/modern-di.md: recipe used modern-di 1.x API (await container.resolve, async-with root Container). 2.x resolution is sync-only and the root container is torn down via close_async(); verified against the upstream 2.x migration docs. Recipe was copy-paste-broken against current modern-di. - dev/contributing.md: "enforced by CI grep gates" was false (no grep runs in CI); corrected to ruff+ty lint pass + review. Added eof-fixer to lint comment. - middleware.md: listed 2 of 4 stable observability contracts; now all four. - resilience.md: AsyncTimeout doc now notes inf/nan rejection (timeout.py:47). Report: planning/audits/2026-06-13-docs-audit.md Change: planning/changes/active/2026-06-13.04-docs-accuracy-fixes/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6d1c816 commit f203821

7 files changed

Lines changed: 288 additions & 12 deletions

File tree

‎docs/dev/contributing.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Thank you for your interest in contributing. `httpware` is an open-source resili
88
git clone https://github.com/modern-python/httpware.git
99
cd httpware
1010
just install # uv lock --upgrade && uv sync --all-extras --frozen --group lint
11-
just lint # ruff format + ruff check + ty check
11+
just lint # eof-fixer + ruff format + ruff check + ty check
1212
just test # pytest with coverage
1313
```
1414

@@ -29,7 +29,9 @@ just test # pytest with coverage
2929

3030
## Architecture invariants
3131

32-
These are enforced by CI grep gates. Do not break them in pull requests:
32+
These are project invariants. The CI lint pass (`just lint-ci` — `ruff` + `ty`)
33+
catches what the linters can see (e.g. `print()` via ruff `T20`); the rest are
34+
enforced in code review. Do not break them in pull requests:
3335

3436
- No `httpx2._*` (private API) usage anywhere in the library.
3537
- No `from __future__ import annotations`.

‎docs/middleware.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ async def main() -> None:
101101
await client.get("/users/1")
102102
```
103103

104-
A note on logger names: the example logs under `myapp.request_id`, NOT under `httpware.*`. The `httpware.*` namespace is reserved for events emitted by the library itself (see [Observability](index.md#observability) — `httpware.retry` and `httpware.bulkhead` are stable contracts). Consumer middleware should use your application's own logger namespace.
104+
A note on logger names: the example logs under `myapp.request_id`, NOT under `httpware.*`. The `httpware.*` namespace is reserved for events emitted by the library itself (see [Observability](index.md#observability) — `httpware.retry`, `httpware.bulkhead`, `httpware.circuit_breaker`, and `httpware.timeout` are stable contracts). Consumer middleware should use your application's own logger namespace.
105105

106106
The example pairs naturally with the 0.6.0 observability events: a `httpware.retry` `retry.giving_up` log record carries a `url` attribute, and your `RequestIdMiddleware` set an `X-Request-Id` for that same call. Correlate the two in your log aggregator and you have end-to-end visibility from "this user's request" to "we gave up after N retries."
107107

‎docs/recipes/modern-di.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,26 @@ class ServiceClients(Group):
2020

2121

2222
async def main() -> None:
23-
async with Container(scope=Scope.APP, groups=[ServiceClients]) as container:
24-
client = await container.resolve(AsyncClient)
23+
container = Container(scope=Scope.APP, groups=[ServiceClients])
24+
try:
25+
client = container.resolve(AsyncClient)
2526
response = await client.get("/users/1")
2627
print(response.status_code)
28+
finally:
29+
await container.close_async() # runs the AsyncClient.aclose finalizer
2730
```
2831

32+
> **modern-di 2.x.** Resolution is sync — `container.resolve(...)`, no `await`.
33+
> The root container is created plainly and torn down with `await
34+
> container.close_async()` (the `async with` form is for
35+
> `build_child_container(...)`, not the root). On modern-di 1.x, resolution was
36+
> awaited; pin accordingly if you are still on 1.x.
37+
2938
Breaking that down:
3039

3140
- **`Scope.APP`** ties the client to the application lifetime. One client per process; the connection pool is reused across all calls.
3241
- **`cache_settings=providers.CacheSettings(...)`** is what makes the provider a singleton. Without it, `Factory` returns a fresh `AsyncClient` on every resolve.
33-
- **`finalizer=AsyncClient.aclose`** is the unbound async method. `modern-di` detects it as a coroutine function (via `inspect.iscoroutinefunction`) and `await`s it on container teardown.
42+
- **`finalizer=AsyncClient.aclose`** is the unbound async method. `modern-di` detects the async finalizer and `await`s it on container teardown (here, on `close_async()`).
3443

3544
A common first instinct here is `finalizer=lambda c: c.aclose()`. **That does not work** — the lambda itself is sync, so `modern-di` calls it synchronously and discards the returned coroutine unawaited. The underlying connection pool leaks. Pass the unbound async method directly, or wrap in `async def`.
3645

@@ -96,10 +105,13 @@ class ServiceClients(Group):
96105

97106

98107
async def main() -> None:
99-
async with Container(scope=Scope.APP, groups=[ServiceClients]) as container:
100-
users = await container.resolve(UserApi)
101-
billing = await container.resolve(BillingApi)
108+
container = Container(scope=Scope.APP, groups=[ServiceClients])
109+
try:
110+
users = container.resolve(UserApi)
111+
billing = container.resolve(BillingApi)
102112
# ... use them
113+
finally:
114+
await container.close_async()
103115
```
104116

105117
A couple of notes:

‎docs/resilience.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ A Finagle-style token bucket bounding retry rate. Each request deposits a token;
6767
### The token-bucket formula
6868

6969
```
70-
ceiling = int(len(deposits_in_window) * percent_can_retry) + int(min_retries_per_sec * ttl)
70+
ceiling = ceil(len(deposits_in_window) * percent_can_retry) + int(min_retries_per_sec * ttl)
7171
```
7272

73+
The percent term rounds **up** (`math.ceil`), so even a handful of recent
74+
deposits permits at least one retry above the floor; the floor term truncates
75+
(`int`).
76+
7377
A withdrawal fails when `len(withdrawn_in_window) >= ceiling`.
7478

7579
### Why a floor matters
@@ -233,7 +237,7 @@ Bounds total wall-clock time across the entire inner pipeline. Place it outermos
233237

234238
| Parameter | Default | Effect |
235239
|---|---|---|
236-
| `timeout` | **REQUIRED** | Overall deadline in seconds. Must be `> 0`; `≤0` raises `ValueError`. |
240+
| `timeout` | **REQUIRED** | Overall deadline in seconds. Must be a finite number `> 0`; a non-finite (`inf`/`nan`) or `≤0` value raises `ValueError`. |
237241

238242
**This is not a per-call timeout.** httpx2's connect/read/write/pool timeouts are the right tool for bounding a single outbound call; `AsyncTimeout` doesn't duplicate them. What httpx2 cannot bound is the total wall-clock across a whole retry sequence — `AsyncTimeout` fills that gap.
239243

‎planning/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ carry **no** frontmatter — living prose, dated by git.
7070

7171
### Active
7272

73-
_None._
73+
- **[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).
7474

7575
### Archived (shipped)
7676

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# httpware documentation audit — 2026-06-13
2+
3+
**Status:** complete
4+
**Scope:** the user-facing documentation surface — `README.md`, the published
5+
mkdocs site (`docs/index.md`, `docs/errors.md`, `docs/middleware.md`,
6+
`docs/resilience.md`, `docs/testing.md`, `docs/recipes/*`, `docs/dev/contributing.md`),
7+
and `mkdocs.yml`. The internal `architecture/` and `planning/` trees were used
8+
as the source-of-truth cross-reference, not themselves audited.
9+
**Method:** four parallel auditors over distinct slices (README+index accuracy,
10+
capability-page accuracy, recipe accuracy, and a cross-corpus readability/UX/navigation
11+
pass). Every concrete code/API claim was verified against the real source in
12+
`src/httpware/`; the modern-di recipe claim was verified against the official
13+
modern-di 2.x migration docs via Context7. The five headline accuracy findings
14+
were reproduced directly against the code before this report was written.
15+
16+
## Summary
17+
18+
- Bugs (false claim / broken copy-paste example): **2**
19+
- Inconsistencies (contradicts code or another doc): **3**
20+
- Readability: **3**
21+
- Onboarding / UX gaps: **6**
22+
23+
**Verdict.** For a developer who already knows what they want, these docs are
24+
strong: the reference pages (`errors.md`, `resilience.md`) are thorough and —
25+
`errors.md` and `testing.md` entirely — accurate down to byte-for-byte hint
26+
strings and payload-field lists; example code is unusually well-commented;
27+
terminology discipline is excellent (always `httpx2`, `AsyncClient`/`Client`,
28+
consistent capability names). The weaknesses cluster in two places: **a
29+
newcomer's first ten minutes** (no "why httpware", a non-resolving first
30+
example, no base-client migration path) and **a maintenance surface that will
31+
silently drift** (README ↔ index.md duplicate ~70% of their prose, including the
32+
full observability contract). Of ~30 checkable claims on the landing pages,
33+
accuracy was near-perfect; the real bugs live in deeper reference prose and the
34+
dev/recipe docs.
35+
36+
## Findings
37+
38+
### Bugs
39+
40+
**B1 — `docs/resilience.md:70` — RetryBudget token-bucket formula is wrong (rounding).**
41+
The doc gives `ceiling = int(len(deposits_in_window) * percent_can_retry) + int(min_retries_per_sec * ttl)`.
42+
The code (`src/httpware/middleware/resilience/budget.py:68`) is
43+
`math.ceil(len(self._deposits) * self._percent_can_retry) + floor`, where
44+
`floor = int(self._min_retries_per_sec * self._ttl)`. The percent term is
45+
`math.ceil`, not `int` — a material difference, not a rounding nitpick. With the
46+
default `percent_can_retry=0.2`: 3 deposits → doc says +0, code yields +1;
47+
11 deposits → doc says +2, code yields +3. Anyone sizing a budget against the
48+
documented formula under-counts permitted retries.
49+
*Fix:* change the percent term to `ceil(...)`; the floor term stays `int(...)`.
50+
51+
**B2 — `docs/recipes/modern-di.md:24,33,99–101` — recipe is broken against current modern-di (2.x).**
52+
The recipe uses `await container.resolve(...)` and `async with Container(scope=…, groups=[…]) as container:`.
53+
Both are modern-di **1.x** shapes. Verified against the modern-di 2.x migration
54+
docs (Context7, `/modern-python/modern-di`): resolution in 2.x is **sync-only**
55+
— "the `await` keywords used for asynchronous resolution in 1.x are removed …
56+
use `container.resolve(SomeType)` directly." The root `Container` is constructed
57+
plainly and torn down with `await container.close_async()`; `async with` is shown
58+
only for `build_child_container(...)`, never the root container. The recipe links
59+
to the live (2.x) docs, so a user copy-pasting today hits `TypeError` on the
60+
`await`. The `inspect.iscoroutinefunction` claim at line 33 is an unverifiable
61+
internal-mechanism detail and should be softened to the observable behaviour.
62+
*Fix:* drop `await` on every `resolve(...)`; construct the root container plainly
63+
and close it in a `try/finally` via `await container.close_async()`; soften the
64+
finalizer-detection sentence.
65+
66+
### Inconsistencies
67+
68+
**I1 — `docs/dev/contributing.md:32` — "enforced by CI grep gates" is false.**
69+
No `grep` runs in any `.github/workflows/` file. CI runs only `just install
70+
lint-ci` (ruff + ty) and `just test`. The `httpx2._` grep described in
71+
`CLAUDE.md`/`architecture/overview.md` as CI-enforced is **not wired into any
72+
workflow** — it is enforced, if at all, by review. (Separately: `CLAUDE.md` and
73+
`architecture/overview.md` both assert these invariants are "CI-enforced / CI
74+
rejects PRs"; that is true for `print()` via ruff `T20` but overstated for the
75+
`httpx2._` and `from __future__` rules. Triage item, not fixed here — see below.)
76+
*Fix:* replace "CI grep gates" with the accurate enforcement (CI lint pass:
77+
ruff + ty; review for the rest).
78+
79+
**I2 — `docs/dev/contributing.md:11` — `just lint` comment omits `eof-fixer`.**
80+
The inline comment reads `# ruff format + ruff check + ty check`; the real target
81+
(`justfile:7–11`) runs four steps — `eof-fixer .`, `ruff format`, `ruff check
82+
--fix`, `ty check`. `CLAUDE.md` lists all four correctly.
83+
*Fix:* add `eof-fixer` to the comment.
84+
85+
**I3 — `docs/middleware.md:104` — stale "stable contracts" list (two of four).**
86+
Names only `httpware.retry` and `httpware.bulkhead` as stable observability
87+
contracts. Since the 0.10.0 circuit-breaker/timeout work, `httpware.circuit_breaker`
88+
and `httpware.timeout` are equally stable — `index.md:153–159` lists all four and
89+
`architecture/resilience.md:23` states their event names "join `retry.*` /
90+
`bulkhead.*` as the stable observability surface; renames are breaking changes."
91+
*Fix:* list all four.
92+
93+
(Also reclassified during audit: **`docs/resilience.md:236`** — the `AsyncTimeout`
94+
parameter table says only "`≤0` raises `ValueError`", but the code
95+
(`timeout.py:47`) and `architecture/resilience.md:17` also reject non-finite
96+
values (`inf`/`nan`). Narrower than both code and the truth home. Fixed with the
97+
batch.)
98+
99+
### Readability
100+
101+
**R1 — dense stacked-qualifier sentences in the hottest spots.** `README.md:31`
102+
(decoder resolution) stacks two qualified clauses; the `respect_retry_after`
103+
cell in `docs/resilience.md:24` is a four-sentence paragraph inside a table cell.
104+
Correct, but hard on first read. *Suggest:* split the densest sentences/cells.
105+
106+
**R2 — unglossed jargon on first use.** "Finagle-style" (`README.md:17`),
107+
"full-jitter", "bulkhead", "PEP 678 note", "token bucket" — most are defined
108+
later or never, but the README is first contact. *Suggest:* a one-clause gloss on
109+
first use.
110+
111+
**R3 — `_LOGGER` used in `errors.md` examples (lines 77, 130, 154) without
112+
definition.** A literal copy-paste hits `NameError`. Minor/conventional.
113+
114+
### Onboarding & UX gaps (the larger lane — not bugs)
115+
116+
These are the high-leverage adoption improvements; they require design, not
117+
mechanical edits, and are recorded here for triage rather than fixed in the
118+
accompanying change.
119+
120+
- **G1 — No "why httpware".** Both README and index lead with "thin wrapper over
121+
httpx2" + a feature list. The actual selling points (typed errors without
122+
`raise_for_status()`; typed bodies via `response_model=`) are buried mid-page.
123+
*Suggest:* a 3-bullet "Why httpware" block up top.
124+
- **G2 — No base-client migration guide.** httpware explicitly supersedes
125+
community-of-python/base-client, yet that inbound audience has zero signpost in
126+
the user docs. *Suggest:* `docs/migration-from-base-client.md`.
127+
- **G3 — README ↔ index.md ~70% duplicated**, including the entire observability
128+
contract table — guaranteed to drift on the next logger/event change. *Suggest:*
129+
one canonical home (docs/); README becomes value-prop + install + one quickstart
130+
+ links.
131+
- **G4 — First quickstart hits `https://example.test`**, which resolves to
132+
nothing — a newcomer's first paste yields `NetworkError`, not data. *Suggest:* a
133+
real public test endpoint for the leading example.
134+
- **G5 — `STATUS_TO_EXCEPTION` is a public `__all__` export
135+
(`src/httpware/__init__.py:54`) documented nowhere.** The lone undocumented
136+
public symbol. *Suggest:* document it (it is the extensible status→exception
137+
map) or reconsider its place in `__all__`.
138+
- **G6 — No custom-`ResponseDecoder` guide and no API reference.** The decoder
139+
seam (Seam B) is a documented extension point but, unlike middleware, gets no
140+
"write your own" guide; and there is no generated symbol reference
141+
(mkdocstrings) for constructor/method signatures. *Suggest:* a short Decoders
142+
guide + minimal mkdocstrings page.
143+
144+
Navigation nits (LOW): mkdocs nav orders Resilience before Middleware though
145+
Resilience is built on it and forward-references it; several `architecture/*.md`
146+
references in published pages are bare paths, not links, so a site reader cannot
147+
follow them. No orphan pages and no broken nav targets — the nav is otherwise clean.
148+
149+
### Verified correct (negative results)
150+
151+
- `README.md` + `docs/index.md`: every import resolves against `__all__`; all
152+
constructor signatures/params (`base_url`, `decoders`, `response_model`,
153+
`httpx2_client`, `middleware`, `max_concurrent`), the exception tree, the extras
154+
(`httpware[pydantic|msgspec|otel|all]` vs `pyproject.toml`), and the behavioural
155+
claims (auto-raise on 4xx/5xx, `MissingDecoderError` before the HTTP call,
156+
pydantic-first default, stream bypasses middleware + pre-reads body on error,
157+
`_emit_event` OTel call) are literally true. Zero bugs.
158+
- `docs/errors.md`: exception tree, `STATUS_TO_EXCEPTION` mapping table, the
159+
400≤status<600 fallback, the StatusError-single-positional / ClientError-keyword
160+
`__init__` split, the doubly-inherited `TimeoutError`, every resilience-error
161+
payload field, and both `MissingDecoderError` hint strings match `errors.py`
162+
byte-for-byte.
163+
- `docs/resilience.md`: every constructor default verified against source
164+
(`max_attempts=3`, `base_delay=0.1`, `max_delay=5.0`,
165+
`retry_status_codes={408,429,502,503,504}`, `failure_threshold=5`,
166+
`reset_timeout=30.0`, `success_threshold=1`, budget `ttl=10.0`,
167+
`min_retries_per_sec=10.0`, `percent_can_retry=0.2`, bulkhead `acquire_timeout=1.0`,
168+
…); 429-as-success, OPEN/HALF_OPEN/CLOSED behaviour, Retry-After parsing/clamping,
169+
and composition ordering all correct.
170+
- `docs/middleware.md`: protocol definitions, `Next`/`AsyncNext` aliases, the three
171+
phase-decorator signatures (incl. `on_error` catching `Exception` not
172+
`BaseException`), and the compose-once-and-frozen claim all match.
173+
- `docs/testing.md`: the `httpx2.MockTransport`-via-`httpx2_client=` pattern matches
174+
the real tests and `architecture/testing.md`; the "`RecordedTransport` removed"
175+
reference is correct.
176+
- `docs/recipes/link-header-pagination.md` and `phase-decorator-patterns.md`: every
177+
API call matches source; both are copy-paste-safe.
178+
179+
## Spawned changes
180+
181+
- **`2026-06-13.04-docs-accuracy-fixes`** (lightweight) — fixes B1, B2, I1, I2, I3,
182+
and the `AsyncTimeout`-validation wording. All verified against code / official
183+
upstream docs.
184+
185+
## Deferred / triage
186+
187+
- The onboarding & UX gaps (G1–G6) — a separate, larger docs-UX change (de-dup,
188+
why-httpware, base-client migration, runnable quickstart, custom-decoder guide,
189+
API reference). Not yet scheduled.
190+
- The `httpx2._` invariant is documented as CI-enforced (`CLAUDE.md`,
191+
`architecture/overview.md`, and — fixed here — `contributing.md`) but no CI
192+
workflow runs the grep. Either wire `grep -rE 'httpx2\._' src/httpware/` into the
193+
lint workflow (tiny CI change) or downgrade the "CI-enforced" wording in the two
194+
internal truth docs. Parked pending a decision on which.

0 commit comments

Comments
 (0)