Skip to content

Commit 480cea8

Browse files
lesnik512claude
andauthored
docs: UX restructure — thin README, canonical site, runnable example (#60)
* docs(planning): add docs-ux-restructure design + plan Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: remove base-client reference from project overview Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: slim README to a runnable front-door (G1, G3, G4) Why-httpware hook, one runnable typed quickstart against jsonplaceholder, and a Documentation links section. Full quickstart/resilience/streaming/ errors/observability detail now lives canonically in docs/index.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add why-httpware hook and a runnable first example (G1, G4) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: order Middleware before Resilience in nav Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: link bare architecture/*.md references to GitHub source Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(planning): mark docs-UX findings resolved + index the bundle 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 65271f1 commit 480cea8

13 files changed

Lines changed: 691 additions & 125 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Guidance for AI agents (Claude Code, etc.) working in this repository.
44

55
## Project Overview
66

7-
`httpware` is a Python HTTP client framework with sync and async clients for building resilient service clients. It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework is a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx.
7+
`httpware` is a Python HTTP client framework with sync and async clients for building resilient service clients. It ships under the `modern-python` org and is a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx.
88

99
**Where to find what:**
1010

README.md

Lines changed: 25 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -14,52 +14,33 @@
1414

1515
**A Python HTTP client framework with sync and async clients for building resilient service clients.**
1616

17-
`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.
17+
## Why httpware
18+
19+
- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`.
20+
- **Typed response bodies**`response_model=YourType` decodes the body straight to your pydantic or msgspec model; a missing decoder fails fast, *before* the request goes out.
21+
- **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`.
22+
23+
Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
1824

1925
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
2026
2127
## Install
2228

2329
```bash
2430
pip install httpware # core only — no decoder
25-
pip install httpware[pydantic] # + PydanticDecoder — handles BaseModel + dataclasses + primitives + generics
26-
pip install httpware[msgspec] # + MsgspecDecoder — handles Struct + dataclasses + primitives + generics
27-
pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec
28-
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
31+
pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics
32+
pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics
33+
pip install httpware[pydantic,msgspec] # both BaseModel routes to pydantic, Struct to msgspec
34+
pip install httpware[all] # everything (pydantic, msgspec, otel)
2935
```
3036

31-
`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.
32-
3337
## Quickstart
3438

35-
**Async usage:**
39+
A typed GET against a live API (needs `pip install httpware[pydantic]`):
3640

3741
```python
3842
import asyncio
3943

40-
from httpware import AsyncClient
41-
42-
async def main() -> None:
43-
async with AsyncClient(base_url="https://example.test") as client:
44-
response = await client.get("/users/42")
45-
print(response.json())
46-
47-
asyncio.run(main())
48-
```
49-
50-
**Sync usage:**
51-
52-
```python
53-
from httpware import Client
54-
55-
with Client(base_url="https://example.test") as client:
56-
response = client.get("/users/42")
57-
print(response.json())
58-
```
59-
60-
Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
61-
62-
```python
6344
from httpware import AsyncClient
6445
from pydantic import BaseModel
6546

@@ -70,88 +51,28 @@ class User(BaseModel):
7051

7152

7253
async def main() -> None:
73-
async with AsyncClient(base_url="https://api.example.com") as client:
54+
async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
7455
user = await client.get("/users/1", response_model=User)
75-
print(user.name)
76-
```
77-
78-
### With resilience middleware
79-
80-
Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
56+
print(user.name) # Leanne Graham
8157

82-
The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient``Client` and `AsyncRetry``Retry` for the sync version.
8358

84-
```python
85-
from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
86-
87-
88-
async def main() -> None:
89-
async with AsyncClient(
90-
base_url="https://api.example.com",
91-
middleware=[
92-
AsyncBulkhead(max_concurrent=10), # cap total in-flight
93-
AsyncRetry(), # default: 3 attempts, full-jitter backoff
94-
],
95-
) as client:
96-
user = await client.get("/users/1", response_model=User)
97-
```
98-
99-
Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](https://httpware.modern-python.org/middleware/).
100-
101-
### Streaming responses
102-
103-
For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
104-
105-
```python
106-
from httpware import AsyncClient
107-
108-
109-
async def main() -> None:
110-
async with AsyncClient(base_url="https://api.example.com") as client:
111-
async with client.stream("GET", "/big-file") as response:
112-
async for chunk in response.aiter_bytes():
113-
process(chunk)
114-
```
115-
116-
`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
117-
118-
It does NOT pass through the middleware chain: `AsyncRetry`, `AsyncBulkhead`, and any custom middleware are bypassed. (AsyncRetry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
119-
120-
## Errors
121-
122-
All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `ServiceUnavailableError`, `RateLimitedError`, etc. — all subclasses of `httpware.StatusError`. Transport-layer transient failures raise `NetworkError`; the resilience middleware raise `RetryBudgetExhaustedError`, `BulkheadFullError`, and `CircuitOpenError`. Everything inherits `httpware.ClientError`.
123-
124-
## Observability
125-
126-
All resilience middleware emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
127-
128-
Logger names and event names are the stable public contract: `httpware.retry` (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`), `httpware.bulkhead` (`bulkhead.rejected`), `httpware.circuit_breaker` (`circuit.opened`, `circuit.rejected`, `circuit.half_open`, `circuit.closed`), and `httpware.timeout` (`timeout.exceeded`).
129-
130-
```python
131-
import logging
132-
133-
# Enable visibility into resilience operational events
134-
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
135-
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
136-
logging.getLogger("httpware.circuit_breaker").setLevel(logging.INFO) # INFO: includes recovery events (half_open, closed)
137-
logging.getLogger("httpware.timeout").setLevel(logging.WARNING)
138-
```
139-
140-
For OTel attribute enrichment on the active span — install the extra:
141-
142-
```bash
143-
pip install httpware[otel]
59+
asyncio.run(main())
14460
```
14561

146-
When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately.
62+
The sync `Client` is identical — swap `AsyncClient``Client` and drop the `await` / `async with`. A 4xx/5xx response raises a typed `StatusError`; a malformed body raises `DecodeError`. Both subclass `httpware.ClientError`.
14763

148-
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
64+
## Documentation
14965

150-
## 📚 [Documentation](https://httpware.modern-python.org)
66+
Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**:
15167

152-
## 📦 [PyPI](https://pypi.org/project/httpware)
68+
- **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract.
69+
- **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation).
70+
- **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout.
71+
- **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies.
72+
- **[Testing](https://httpware.modern-python.org/testing/)**`httpx2.MockTransport` injection.
73+
- **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination.
15374

154-
## 📝 [License](LICENSE)
75+
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](LICENSE)
15576

15677
## Part of `modern-python`
15778

docs/errors.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,4 @@ Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic
191191

192192
- **[Resilience reference](resilience.md)**`AsyncRetry`, `RetryBudget`, `AsyncBulkhead` parameter tables.
193193
- **[Middleware guide](middleware.md)** — the `@async_on_error` decorator can translate exceptions into responses.
194-
- **`architecture/errors.md`** — the formal exception contract.
194+
- **[`architecture/errors.md`](https://github.com/modern-python/httpware/blob/main/architecture/errors.md)** — the formal exception contract.

docs/index.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
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` as the public request/response surface, adds a middleware chain (with a built-in resilience suite: `AsyncRetry`/`Retry` + `RetryBudget`, `AsyncBulkhead`/`Bulkhead`), opt-in typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx.
44

5+
## Why httpware
6+
7+
- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`.
8+
- **Typed response bodies**`response_model=YourType` decodes the body straight to your pydantic or msgspec model; a missing decoder fails fast, *before* the request goes out.
9+
- **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`.
10+
511
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
612
713
## Install
@@ -28,8 +34,8 @@ import asyncio
2834
from httpware import AsyncClient
2935

3036
async def main() -> None:
31-
async with AsyncClient(base_url="https://example.test") as client:
32-
response = await client.get("/users/42")
37+
async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
38+
response = await client.get("/users/1")
3339
print(response.json())
3440

3541
asyncio.run(main())
@@ -40,8 +46,8 @@ asyncio.run(main())
4046
```python
4147
from httpware import Client
4248

43-
with Client(base_url="https://example.test") as client:
44-
response = client.get("/users/42")
49+
with Client(base_url="https://jsonplaceholder.typicode.com") as client:
50+
response = client.get("/users/1")
4551
print(response.json())
4652
```
4753

docs/middleware.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ The example pairs naturally with the 0.6.0 observability events: a `httpware.ret
110110
- **Redaction:** Use a `logging.Filter` on the consumer side. `httpware` deliberately does no redaction in-library (per the 0.6.0 observability design).
111111
- **URL or header validation:** `httpx2` owns it. Don't reimplement.
112112
- **Per-call behavior that doesn't apply to other calls:** Pass through `request.extensions=` (or the `extensions=` kwarg at the call site) instead. Middleware exists for *cross-cutting* concerns.
113-
- **HTTP-level span creation for tracing:** Install `opentelemetry-instrumentation-httpx` instead of writing an OTel middleware in httpware. We retired story `5-4` (standalone OTel middleware) for this reason — `opentelemetry-instrumentation-httpx` already covers transport-level tracing, and a separate httpware layer would duplicate it. See `architecture/middleware.md`.
113+
- **HTTP-level span creation for tracing:** Install `opentelemetry-instrumentation-httpx` instead of writing an OTel middleware in httpware. We retired story `5-4` (standalone OTel middleware) for this reason — `opentelemetry-instrumentation-httpx` already covers transport-level tracing, and a separate httpware layer would duplicate it. See [`architecture/middleware.md`](https://github.com/modern-python/httpware/blob/main/architecture/middleware.md).
114114

115115
## Wiring OpenTelemetry
116116

@@ -198,6 +198,6 @@ Sync and async middleware classes do not interop: a `Middleware` cannot be passe
198198

199199
## See also
200200

201-
- **`architecture/middleware.md` (Seam A)** — the formal protocol contract and why the chain is frozen at construction.
201+
- **[`architecture/middleware.md`](https://github.com/modern-python/httpware/blob/main/architecture/middleware.md) (Seam A)** — the formal protocol contract and why the chain is frozen at construction.
202202
- **`src/httpware/middleware/resilience/`**`AsyncRetry`, `AsyncBulkhead`, `RetryBudget` as real-world consumers of this exact protocol.
203203
- **[Quick-Start composition example](index.md#with-resilience-middleware)** — composing built-in middleware.

docs/resilience.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,4 @@ with Client(
358358
- **[Middleware guide](middleware.md)** — write your own resilience middleware against the same protocol `AsyncRetry` and `AsyncBulkhead` use.
359359
- **[Errors reference](errors.md)**`RetryBudgetExhaustedError`, `BulkheadFullError`, `CircuitOpenError`, and the broader exception tree.
360360
- **[Observability](index.md#observability)** — the operational events these middleware emit.
361-
- **`architecture/middleware.md`** — the formal Middleware/Seam-A contract.
361+
- **[`architecture/middleware.md`](https://github.com/modern-python/httpware/blob/main/architecture/middleware.md)** — the formal Middleware/Seam-A contract.

docs/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,4 @@ For middleware with state-keeping (counters, circuit-breaker state), assert on i
111111

112112
- **[Middleware guide](middleware.md)** — write the middleware you're testing.
113113
- **[Resilience reference](resilience.md)** — testing `AsyncRetry`/`AsyncBulkhead` configurations.
114-
- **`architecture/testing.md`** — the project's own testing patterns (Hypothesis property-based tests, `pytest-asyncio` auto-mode, the `RecordedTransport`-was-removed history).
114+
- **[`architecture/testing.md`](https://github.com/modern-python/httpware/blob/main/architecture/testing.md)** — the project's own testing patterns (Hypothesis property-based tests, `pytest-asyncio` auto-mode, the `RecordedTransport`-was-removed history).

mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ edit_uri: edit/main/docs/
66

77
nav:
88
- Quick-Start: index.md
9-
- Resilience: resilience.md
109
- Middleware: middleware.md
10+
- Resilience: resilience.md
1111
- Errors: errors.md
1212
- Testing: testing.md
1313
- Recipes:

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-ux-restructure](changes/active/2026-06-14.01-docs-ux-restructure/design.md)** (draft, 2026-06-14) — Thin README front-door + canonical `docs/index.md`, why-httpware hook (G1), runnable first example (G4), nav nits, base-client scrub. G2 dropped, G6 deferred.
7474

7575
### Archived (shipped)
7676

planning/audits/2026-06-13-docs-audit.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,17 +125,20 @@ accompanying change.
125125
- **G1 — No "why httpware".** Both README and index lead with "thin wrapper over
126126
httpx2" + a feature list. The actual selling points (typed errors without
127127
`raise_for_status()`; typed bodies via `response_model=`) are buried mid-page.
128-
*Suggest:* a 3-bullet "Why httpware" block up top.
129-
- **G2 — No base-client migration guide.** httpware explicitly supersedes
130-
community-of-python/base-client, yet that inbound audience has zero signpost in
131-
the user docs. *Suggest:* `docs/migration-from-base-client.md`.
128+
**Resolved** (`2026-06-14.01`) — 3-bullet "Why httpware" block added to the top
129+
of both README and `docs/index.md`.
130+
- **G2 — No base-client migration guide.** **Won't do** (`2026-06-14.01`) — per
131+
the maintainer, base-client is scrubbed entirely, not documented; the lone live
132+
mention (`CLAUDE.md`) was removed and no migration guide is written.
132133
- **G3 — README ↔ index.md ~70% duplicated**, including the entire observability
133-
contract table — guaranteed to drift on the next logger/event change. *Suggest:*
134-
one canonical home (docs/); README becomes value-prop + install + one quickstart
135-
+ links.
134+
contract table — guaranteed to drift on the next logger/event change. **Resolved**
135+
(`2026-06-14.01`) — README slimmed to a front-door (why + install + one runnable
136+
quickstart + links); `docs/index.md` is now the single canonical home for the
137+
full quickstart/resilience/streaming/errors/observability content.
136138
- **G4 — First quickstart hits `https://example.test`**, which resolves to
137-
nothing — a newcomer's first paste yields `NetworkError`, not data. *Suggest:* a
138-
real public test endpoint for the leading example.
139+
nothing — a newcomer's first paste yields `NetworkError`, not data. **Resolved**
140+
(`2026-06-14.01`) — leading examples (README + `docs/index.md`) now hit
141+
`jsonplaceholder.typicode.com/users/1`; verified to return a decoded `User` live.
139142
- **G5 — `STATUS_TO_EXCEPTION` is a public `__all__` export
140143
(`src/httpware/__init__.py:54`) documented nowhere.** The lone undocumented
141144
public symbol. **Resolved** (`2026-06-13.05`) — documented at the
@@ -151,6 +154,11 @@ Navigation nits (LOW): mkdocs nav orders Resilience before Middleware though
151154
Resilience is built on it and forward-references it; several `architecture/*.md`
152155
references in published pages are bare paths, not links, so a site reader cannot
153156
follow them. No orphan pages and no broken nav targets — the nav is otherwise clean.
157+
**Resolved** (`2026-06-14.01`) — nav reordered (Middleware before Resilience) and
158+
all five bare `architecture/*.md` references converted to absolute GitHub links.
159+
160+
Only **G6** (custom-`ResponseDecoder` guide; no API reference per maintainer)
161+
remains open after `2026-06-14.01`.
154162

155163
### Verified correct (negative results)
156164

0 commit comments

Comments
 (0)