diff --git a/CLAUDE.md b/CLAUDE.md index bcf8c94..4b3b770 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for AI agents (Claude Code, etc.) working in this repository. ## Project Overview -`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. +`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. **Where to find what:** diff --git a/README.md b/README.md index 10d3276..2650b44 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,13 @@ **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 `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. +## Why httpware + +- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`. +- **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. +- **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`. + +Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction. > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. @@ -22,44 +28,19 @@ ```bash pip install httpware # core only — no decoder -pip install httpware[pydantic] # + PydanticDecoder — handles BaseModel + dataclasses + primitives + generics -pip install httpware[msgspec] # + MsgspecDecoder — handles Struct + dataclasses + primitives + generics -pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec -pip install httpware[all] # everything declared above (pydantic, msgspec, otel) +pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics +pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics +pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec +pip install httpware[all] # everything (pydantic, msgspec, otel) ``` -`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 -**Async usage:** +A typed GET against a live API (needs `pip install httpware[pydantic]`): ```python import asyncio -from httpware import AsyncClient - -async def main() -> None: - async with AsyncClient(base_url="https://example.test") as client: - response = await client.get("/users/42") - print(response.json()) - -asyncio.run(main()) -``` - -**Sync usage:** - -```python -from httpware import Client - -with Client(base_url="https://example.test") as client: - response = client.get("/users/42") - print(response.json()) -``` - -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. - -```python from httpware import AsyncClient from pydantic import BaseModel @@ -70,88 +51,28 @@ class User(BaseModel): async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: + async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client: user = await client.get("/users/1", response_model=User) - print(user.name) -``` - -### With resilience middleware - -Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts. + print(user.name) # Leanne Graham -The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient` → `Client` and `AsyncRetry` → `Retry` for the sync version. -```python -from httpware import AsyncClient, AsyncBulkhead, AsyncRetry - - -async def main() -> None: - async with AsyncClient( - base_url="https://api.example.com", - middleware=[ - AsyncBulkhead(max_concurrent=10), # cap total in-flight - AsyncRetry(), # default: 3 attempts, full-jitter backoff - ], - ) as client: - user = await client.get("/users/1", response_model=User) -``` - -Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](https://httpware.modern-python.org/middleware/). - -### Streaming responses - -For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager: - -```python -from httpware import AsyncClient - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: - async with client.stream("GET", "/big-file") as response: - async for chunk in response.aiter_bytes(): - process(chunk) -``` - -`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception. - -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.) - -## Errors - -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`. - -## Observability - -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. - -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`). - -```python -import logging - -# Enable visibility into resilience operational events -logging.getLogger("httpware.retry").setLevel(logging.WARNING) -logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING) -logging.getLogger("httpware.circuit_breaker").setLevel(logging.INFO) # INFO: includes recovery events (half_open, closed) -logging.getLogger("httpware.timeout").setLevel(logging.WARNING) -``` - -For OTel attribute enrichment on the active span — install the extra: - -```bash -pip install httpware[otel] +asyncio.run(main()) ``` -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. +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`. -## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) +## Documentation -## 📚 [Documentation](https://httpware.modern-python.org) +Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**: -## 📦 [PyPI](https://pypi.org/project/httpware) +- **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract. +- **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation). +- **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout. +- **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies. +- **[Testing](https://httpware.modern-python.org/testing/)** — `httpx2.MockTransport` injection. +- **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination. -## 📝 [License](LICENSE) +## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](LICENSE) ## Part of `modern-python` diff --git a/docs/errors.md b/docs/errors.md index bb6d1b5..bca6482 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -191,4 +191,4 @@ Unlike `DecodeError`, this error fires *before* the HTTP request — no traffic - **[Resilience reference](resilience.md)** — `AsyncRetry`, `RetryBudget`, `AsyncBulkhead` parameter tables. - **[Middleware guide](middleware.md)** — the `@async_on_error` decorator can translate exceptions into responses. -- **`architecture/errors.md`** — the formal exception contract. +- **[`architecture/errors.md`](https://github.com/modern-python/httpware/blob/main/architecture/errors.md)** — the formal exception contract. diff --git a/docs/index.md b/docs/index.md index e8f60c8..3640111 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,12 @@ 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. +## Why httpware + +- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`. +- **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. +- **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`. + > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. ## Install @@ -28,8 +34,8 @@ import asyncio from httpware import AsyncClient async def main() -> None: - async with AsyncClient(base_url="https://example.test") as client: - response = await client.get("/users/42") + async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client: + response = await client.get("/users/1") print(response.json()) asyncio.run(main()) @@ -40,8 +46,8 @@ asyncio.run(main()) ```python from httpware import Client -with Client(base_url="https://example.test") as client: - response = client.get("/users/42") +with Client(base_url="https://jsonplaceholder.typicode.com") as client: + response = client.get("/users/1") print(response.json()) ``` diff --git a/docs/middleware.md b/docs/middleware.md index 23b8cdd..0549de0 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -110,7 +110,7 @@ The example pairs naturally with the 0.6.0 observability events: a `httpware.ret - **Redaction:** Use a `logging.Filter` on the consumer side. `httpware` deliberately does no redaction in-library (per the 0.6.0 observability design). - **URL or header validation:** `httpx2` owns it. Don't reimplement. - **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. -- **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`. +- **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). ## Wiring OpenTelemetry @@ -198,6 +198,6 @@ Sync and async middleware classes do not interop: a `Middleware` cannot be passe ## See also -- **`architecture/middleware.md` (Seam A)** — the formal protocol contract and why the chain is frozen at construction. +- **[`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. - **`src/httpware/middleware/resilience/`** — `AsyncRetry`, `AsyncBulkhead`, `RetryBudget` as real-world consumers of this exact protocol. - **[Quick-Start composition example](index.md#with-resilience-middleware)** — composing built-in middleware. diff --git a/docs/resilience.md b/docs/resilience.md index fb8ab63..e6603a1 100644 --- a/docs/resilience.md +++ b/docs/resilience.md @@ -358,4 +358,4 @@ with Client( - **[Middleware guide](middleware.md)** — write your own resilience middleware against the same protocol `AsyncRetry` and `AsyncBulkhead` use. - **[Errors reference](errors.md)** — `RetryBudgetExhaustedError`, `BulkheadFullError`, `CircuitOpenError`, and the broader exception tree. - **[Observability](index.md#observability)** — the operational events these middleware emit. -- **`architecture/middleware.md`** — the formal Middleware/Seam-A contract. +- **[`architecture/middleware.md`](https://github.com/modern-python/httpware/blob/main/architecture/middleware.md)** — the formal Middleware/Seam-A contract. diff --git a/docs/testing.md b/docs/testing.md index f9a4a2e..35d4c3f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -111,4 +111,4 @@ For middleware with state-keeping (counters, circuit-breaker state), assert on i - **[Middleware guide](middleware.md)** — write the middleware you're testing. - **[Resilience reference](resilience.md)** — testing `AsyncRetry`/`AsyncBulkhead` configurations. -- **`architecture/testing.md`** — the project's own testing patterns (Hypothesis property-based tests, `pytest-asyncio` auto-mode, the `RecordedTransport`-was-removed history). +- **[`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). diff --git a/mkdocs.yml b/mkdocs.yml index 46a6728..b7cbef1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,8 +6,8 @@ edit_uri: edit/main/docs/ nav: - Quick-Start: index.md - - Resilience: resilience.md - Middleware: middleware.md + - Resilience: resilience.md - Errors: errors.md - Testing: testing.md - Recipes: diff --git a/planning/README.md b/planning/README.md index a8ed787..12b33f1 100644 --- a/planning/README.md +++ b/planning/README.md @@ -70,7 +70,7 @@ carry **no** frontmatter — living prose, dated by git. ### Active -_None._ +- **[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. ### Archived (shipped) diff --git a/planning/audits/2026-06-13-docs-audit.md b/planning/audits/2026-06-13-docs-audit.md index a86505d..5d07246 100644 --- a/planning/audits/2026-06-13-docs-audit.md +++ b/planning/audits/2026-06-13-docs-audit.md @@ -125,17 +125,20 @@ accompanying change. - **G1 — No "why httpware".** Both README and index lead with "thin wrapper over httpx2" + a feature list. The actual selling points (typed errors without `raise_for_status()`; typed bodies via `response_model=`) are buried mid-page. - *Suggest:* a 3-bullet "Why httpware" block up top. -- **G2 — No base-client migration guide.** httpware explicitly supersedes - community-of-python/base-client, yet that inbound audience has zero signpost in - the user docs. *Suggest:* `docs/migration-from-base-client.md`. + **Resolved** (`2026-06-14.01`) — 3-bullet "Why httpware" block added to the top + of both README and `docs/index.md`. +- **G2 — No base-client migration guide.** **Won't do** (`2026-06-14.01`) — per + the maintainer, base-client is scrubbed entirely, not documented; the lone live + mention (`CLAUDE.md`) was removed and no migration guide is written. - **G3 — README ↔ index.md ~70% duplicated**, including the entire observability - contract table — guaranteed to drift on the next logger/event change. *Suggest:* - one canonical home (docs/); README becomes value-prop + install + one quickstart - + links. + contract table — guaranteed to drift on the next logger/event change. **Resolved** + (`2026-06-14.01`) — README slimmed to a front-door (why + install + one runnable + quickstart + links); `docs/index.md` is now the single canonical home for the + full quickstart/resilience/streaming/errors/observability content. - **G4 — First quickstart hits `https://example.test`**, which resolves to - nothing — a newcomer's first paste yields `NetworkError`, not data. *Suggest:* a - real public test endpoint for the leading example. + nothing — a newcomer's first paste yields `NetworkError`, not data. **Resolved** + (`2026-06-14.01`) — leading examples (README + `docs/index.md`) now hit + `jsonplaceholder.typicode.com/users/1`; verified to return a decoded `User` live. - **G5 — `STATUS_TO_EXCEPTION` is a public `__all__` export (`src/httpware/__init__.py:54`) documented nowhere.** The lone undocumented public symbol. **Resolved** (`2026-06-13.05`) — documented at the @@ -151,6 +154,11 @@ Navigation nits (LOW): mkdocs nav orders Resilience before Middleware though Resilience is built on it and forward-references it; several `architecture/*.md` references in published pages are bare paths, not links, so a site reader cannot follow them. No orphan pages and no broken nav targets — the nav is otherwise clean. +**Resolved** (`2026-06-14.01`) — nav reordered (Middleware before Resilience) and +all five bare `architecture/*.md` references converted to absolute GitHub links. + +Only **G6** (custom-`ResponseDecoder` guide; no API reference per maintainer) +remains open after `2026-06-14.01`. ### Verified correct (negative results) diff --git a/planning/changes/active/.gitkeep b/planning/changes/active/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/planning/changes/active/2026-06-14.01-docs-ux-restructure/design.md b/planning/changes/active/2026-06-14.01-docs-ux-restructure/design.md new file mode 100644 index 0000000..353d7ea --- /dev/null +++ b/planning/changes/active/2026-06-14.01-docs-ux-restructure/design.md @@ -0,0 +1,172 @@ +--- +status: draft +date: 2026-06-14 +slug: docs-ux-restructure +supersedes: null +superseded_by: null +pr: null +outcome: null +--- + +# Design: Docs-UX restructure — thin README, canonical site, runnable first example + +## Summary + +Resolve the structural onboarding/UX findings from the [docs audit](../../../audits/2026-06-13-docs-audit.md). +`README.md` becomes a thin front-door (value-prop + install + one runnable +quickstart + links), and `docs/index.md` becomes the single canonical home for +the full quickstart, resilience, streaming, errors, and the observability +contract. Adds a 3-bullet "Why httpware" hook (G1), makes the leading example +actually run against a live endpoint (G4), fixes two navigation nits, and scrubs +the lone base-client mention. No code changes. + +## Motivation + +The audit found the user-facing docs strong for an already-bought-in developer +but weak in the first ten minutes and fragile to maintain: + +- **G3 — duplication.** `README.md` and `docs/index.md` share ~70% of their + prose and code, *including the full observability/logger contract table in both + places*. A logger rename or new event must be edited in two files; one will be + missed. This is a live drift hazard, not a style nit. +- **G1 — no "why".** Both pages open with "thin wrapper around `httpx2`" + a + feature list. The actual selling points (typed errors without + `raise_for_status()`; typed bodies via `response_model=`) are buried mid-page. +- **G4 — dead first example.** The leading quickstart targets + `https://example.test` (a reserved no-resolve domain), so a newcomer's first + paste yields `NetworkError` instead of data — directly undercutting "make my + first typed request quickly." +- **Nav nits.** `mkdocs.yml` orders Resilience before Middleware though Resilience + is built on the middleware chain and forward-references it; and several + `architecture/*.md` "see also" pointers are bare inline-code paths that render + as unclickable dead ends on the published site (`docs/index.md` already links + them correctly — the others are just inconsistent). +- **base-client.** `CLAUDE.md` still says httpware "supersedes + `community-of-python/base-client`"; per the maintainer this reference is to be + removed entirely. + +## Non-goals + +- **No base-client migration guide** (audit G2) — dropped entirely; base-client is + scrubbed, not documented. +- **No API reference and no `mkdocstrings`** — explicitly declined; prose docs + already carry the signatures. +- **The custom-`ResponseDecoder` guide (audit G6)** is deferred to its own + follow-up change; not in this bundle. +- **No content rewrite of the capability pages** (`errors.md`, `resilience.md`, + etc.) beyond the two nav-reference fixes — they audited clean. +- **No readability re-edit** — R1/R2/R3 already shipped in `2026-06-13.05`. + +## Design + +### 1. Canonical home — thin README (G3) + +`docs/index.md` is the single source of truth for the full quickstart, resilience, +streaming, errors, and the observability contract table. It already contains all +of this; it stays as-is except for the "Why httpware" addition (§2). + +`README.md` is rewritten to a front-door with this structure: + +```text +# httpware + one-line tagline +## Why httpware ← G1, 3 bullets (new) +> Status: Pre-1.0 … +## Install ← unchanged +## Quickstart ← ONE example only (typed async GET), runnable (§3) +## Documentation ← absolute links into the site + capability pages +## License +``` + +Everything currently duplicated in `README.md` — the "With resilience +middleware" block, "Streaming responses", "Errors", and the **entire +Observability section (logger names + event table + OTel paragraph)** — is +**deleted from the README**. It is not lost: it already lives in `docs/index.md` +and the capability pages, which become its only home. + +**Load-bearing constraint:** PyPI and GitHub render `README.md` *without* mkdocs, +so every doc link in the README must be an **absolute URL** to the published site +(`https://httpware.modern-python.org/…`) or the GitHub repo — never a relative +`docs/…` path (which 404s on PyPI). The published site base is +`httpware.modern-python.org` (from `docs/CNAME`). + +### 2. "Why httpware" hook (G1) + +A three-bullet block immediately after the tagline, in **both** `README.md` and +`docs/index.md` (this is the one intentional small duplication — it is short, +stable, and is the value-prop a reader must see first on either surface): + +- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a + status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …). +- **Typed response bodies** — `response_model=YourType` decodes the body straight + to your pydantic/msgspec model; a missing decoder fails fast, before the call. +- **Production resilience as composable middleware** — retry + retry-budget, + bulkhead, circuit breaker, and timeout, all over standard `httpx2`. + +### 3. Runnable first example (G4) + +In the **leading** quickstart only, replace `https://example.test` with +`https://jsonplaceholder.typicode.com`. Verified live: `GET /users/1` returns +`{"id": 1, "name": "Leanne Graham", "username": …, "email": …, …}`, so the +existing example — + +```python +class User(BaseModel): + id: int + name: str + +user = await client.get("/users/1", response_model=User) +``` + +— decodes verbatim (pydantic ignores the extra fields). Only the `base_url` +host changes; the model and call are untouched. Other, illustrative +(non-runnable) examples across the docs keep `api.example.com`; standardizing the +*runnable* one on jsonplaceholder is the whole change. + +### 4. Navigation nits + +- **Reorder `mkdocs.yml` nav** so **Middleware precedes Resilience** (Resilience + is built on the middleware chain and forward-references "Composition"). Target + order: Quick-Start → Middleware → Resilience → Errors → Testing → Recipes → Dev. +- **Fix bare `architecture/*.md` references** in published pages by converting + them to absolute GitHub source links (matching the pattern `docs/index.md:186` + already uses), so site readers can follow them instead of seeing dead monospace + text. Sites to fix: `docs/middleware.md:113` & `:201`, `docs/errors.md:194`, + `docs/resilience.md:361`, `docs/testing.md:114`. + +### 5. base-client scrub + +`CLAUDE.md:7` — remove the "It supersedes `community-of-python/base-client` and +ships under the `modern-python` org" clause (drop the base-client reference; +keep/fold the rest of the sentence). Only this one live file mentions it; frozen +planning history (`retros/`, `changes/archive/`, `audits/`) is intentionally left +untouched — history is not rewritten. The audit's **G2** is marked "won't do — +base-client scrubbed." + +## Testing + +- `mkdocs build --strict` — clean (no broken refs/links introduced); this is the + primary gate. +- **README links resolve:** every absolute URL in the rewritten README is checked + (the site base + each capability anchor). +- **Runnable example actually runs:** execute the leading quickstart against + `jsonplaceholder.typicode.com/users/1` and confirm it returns a decoded `User`. +- `just lint` — clean (regression check only; no source touched). +- Final diff read: README carries no relative `docs/…` links and no leftover + duplicated observability/contract content. + +## Risk + +- **Third-party uptime (low × low).** The runnable example depends on + jsonplaceholder being up. Mitigation: it is a long-standing demo API; the + failure mode (their outage) is obvious and transient, and the surrounding prose + still reads correctly. +- **README link rot (low × medium).** Absolute site links can break if the site + URL or page anchors change. Mitigation: `mkdocs build --strict` plus the + explicit link check; anchors point at stable top-level pages, not deep headings + where avoidable. +- **Losing detail in the README trim (low × medium).** Deleting the resilience/ + streaming/errors/observability sections from the README could feel like a + regression for README-only readers. Mitigation: the content is fully preserved + in `docs/index.md` + capability pages, and the README's Documentation section + links straight to each — so nothing becomes unreachable, it moves one click + away. diff --git a/planning/changes/active/2026-06-14.01-docs-ux-restructure/plan.md b/planning/changes/active/2026-06-14.01-docs-ux-restructure/plan.md new file mode 100644 index 0000000..d890006 --- /dev/null +++ b/planning/changes/active/2026-06-14.01-docs-ux-restructure/plan.md @@ -0,0 +1,459 @@ +--- +status: draft +date: 2026-06-14 +slug: docs-ux-restructure +spec: docs-ux-restructure +pr: null +--- + +# docs-ux-restructure — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `README.md` a thin runnable front-door, keep `docs/index.md` as the +single canonical home, add a "Why httpware" hook, fix two nav nits, and scrub the +base-client mention — closing audit findings G1, G3, G4, nav, and G2 (won't-do). + +**Spec:** [`design.md`](./design.md) + +**Branch:** `docs/ux-restructure` + +**Commit strategy:** Per-task commits; squash on merge via PR. + +**No code touched** — this is docs only. "Verification" means `mkdocs build +--strict`, a live run of the one example, and `just lint` as a regression check. +There is no pytest for prose. + +--- + +### Task 1: Create branch and commit the planning bundle + +**Files:** +- Create: `planning/changes/active/.gitkeep` (already on disk, uncommitted) +- Create: `planning/changes/active/2026-06-14.01-docs-ux-restructure/design.md` (already on disk) +- Create: `planning/changes/active/2026-06-14.01-docs-ux-restructure/plan.md` (this file) + +Get the design + plan under version control on a fresh branch before editing docs. + +- [ ] **Step 1: Create the branch off the current main** + + ```bash + git checkout main && git pull --ff-only origin main + git checkout -b docs/ux-restructure + ``` + +- [ ] **Step 2: Commit the bundle** + + ```bash + git add planning/changes/active/.gitkeep \ + planning/changes/active/2026-06-14.01-docs-ux-restructure/ + git commit -m "docs(planning): add docs-ux-restructure design + plan + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 2: Scrub the base-client mention + +**Files:** +- Modify: `CLAUDE.md` (the "Project Overview" sentence) + +Remove the only live base-client reference. Frozen history (`retros/`, `archive/`, +`audits/`) is intentionally left alone. + +- [ ] **Step 1: Edit the sentence** + + Find this text in `CLAUDE.md`: + + > It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework is a thin opinionated wrapper around `httpx2`: + + Replace with: + + > It ships under the `modern-python` org and is a thin opinionated wrapper around `httpx2`: + +- [ ] **Step 2: Verify no live base-client mention remains** + + Run: `grep -rin "base-client\|base_client" CLAUDE.md README.md docs/ architecture/` + Expected: no matches (httpx2's own `BaseClient` class lives in `.venv`, which is not searched here). + +- [ ] **Step 3: Commit** + + ```bash + git add CLAUDE.md + git commit -m "docs: remove base-client reference from project overview + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 3: Rewrite README.md to a thin front-door (G1 + G3 + G4) + +**Files:** +- Modify: `README.md` (full rewrite below; badge block preserved verbatim) + +Replace the dense intro + three duplicated quickstart sections + the full +observability table with: a 3-bullet "Why httpware", install, ONE runnable typed +quickstart, and a Documentation links section (absolute URLs — PyPI/GitHub render +without mkdocs). + +- [ ] **Step 1: Overwrite `README.md` with exactly this content** + +````markdown +# httpware + +[![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/) +[![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml) +[![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/modern-python/httpware)](https://github.com/modern-python/httpware/stargazers) +[![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/httpware) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) + +**A Python HTTP client framework with sync and async clients for building resilient service clients.** + +## Why httpware + +- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`. +- **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. +- **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`. + +Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction. + +> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. + +## Install + +```bash +pip install httpware # core only — no decoder +pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics +pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics +pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec +pip install httpware[all] # everything (pydantic, msgspec, otel) +``` + +## Quickstart + +A typed GET against a live API (needs `pip install httpware[pydantic]`): + +```python +import asyncio + +from httpware import AsyncClient +from pydantic import BaseModel + + +class User(BaseModel): + id: int + name: str + + +async def main() -> None: + async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client: + user = await client.get("/users/1", response_model=User) + print(user.name) # Leanne Graham + + +asyncio.run(main()) +``` + +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`. + +## Documentation + +Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**: + +- **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract. +- **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation). +- **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout. +- **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies. +- **[Testing](https://httpware.modern-python.org/testing/)** — `httpx2.MockTransport` injection. +- **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination. + +## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](LICENSE) + +## Part of `modern-python` + +Browse the full list of templates and libraries in +[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index. +```` + +- [ ] **Step 2: Confirm the README has no relative `docs/` links and no leftover duplicated sections** + + Run: `grep -nE '\]\(docs/|## Observability|### With resilience|### Streaming' README.md` + Expected: no matches. + +- [ ] **Step 3: Commit** + + ```bash + git add README.md + git commit -m "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) " + ``` + +--- + +### Task 4: Add "Why httpware" to docs/index.md and make its leading example runnable (G1 + G4) + +**Files:** +- Modify: `docs/index.md` (intro area + the "First request" async & sync examples) + +`docs/index.md` stays the canonical full page; add the same value-prop hook and +fix the dead leading example. jsonplaceholder only serves users 1–10, so the path +must change to `/users/1`, not just the host. + +- [ ] **Step 1: Insert the "Why httpware" block after the intro paragraph** + + Find (the intro paragraph + Status blockquote, lines ~3–5): + + ```markdown + 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. + + > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. + ``` + + Replace with: + + ```markdown + 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. + + ## Why httpware + + - **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`. + - **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. + - **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`. + + > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. + ``` + +- [ ] **Step 2: Make the async leading example runnable** + + Find: + + ```python + async with AsyncClient(base_url="https://example.test") as client: + response = await client.get("/users/42") + ``` + + Replace with: + + ```python + async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client: + response = await client.get("/users/1") + ``` + +- [ ] **Step 3: Make the sync leading example runnable** + + Find: + + ```python + with Client(base_url="https://example.test") as client: + response = client.get("/users/42") + ``` + + Replace with: + + ```python + with Client(base_url="https://jsonplaceholder.typicode.com") as client: + response = client.get("/users/1") + ``` + +- [ ] **Step 4: Confirm the leading examples no longer use the dead host** + + Run: `sed -n '20,50p' docs/index.md | grep -nE 'example.test|/users/42'` + Expected: no matches. (Note: `docs/testing.md` keeps `example.test` — those are MockTransport examples with no real network, intentionally left.) + +- [ ] **Step 5: Commit** + + ```bash + git add docs/index.md + git commit -m "docs: add why-httpware hook and a runnable first example (G1, G4) + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 5: Reorder mkdocs nav — Middleware before Resilience (nav nit) + +**Files:** +- Modify: `mkdocs.yml` (the `nav:` block) + +Resilience is built on the middleware chain and forward-references it, so +Middleware should come first. + +- [ ] **Step 1: Edit the nav order** + + Find: + + ```yaml + nav: + - Quick-Start: index.md + - Resilience: resilience.md + - Middleware: middleware.md + - Errors: errors.md + ``` + + Replace with: + + ```yaml + nav: + - Quick-Start: index.md + - Middleware: middleware.md + - Resilience: resilience.md + - Errors: errors.md + ``` + +- [ ] **Step 2: Commit** + + ```bash + git add mkdocs.yml + git commit -m "docs: order Middleware before Resilience in nav + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 6: Link the bare architecture/*.md references (nav nit) + +**Files:** +- Modify: `docs/middleware.md` (two spots), `docs/errors.md`, `docs/resilience.md`, `docs/testing.md` + +On the published site `architecture/` is not built, so these bare inline-code +paths render as unclickable dead ends. Convert each to an absolute GitHub source +link (matching the pattern `docs/index.md` already uses). + +- [ ] **Step 1: `docs/middleware.md` — the inline "See" reference** + + Find: `See `architecture/middleware.md`.` + Replace with: `See [`architecture/middleware.md`](https://github.com/modern-python/httpware/blob/main/architecture/middleware.md).` + +- [ ] **Step 2: `docs/middleware.md` — the "see also" bullet** + + Find: `- **`architecture/middleware.md` (Seam A)** — the formal protocol contract and why the chain is frozen at construction.` + Replace with: `- **[`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.` + +- [ ] **Step 3: `docs/errors.md`** + + Find: `- **`architecture/errors.md`** — the formal exception contract.` + Replace with: `- **[`architecture/errors.md`](https://github.com/modern-python/httpware/blob/main/architecture/errors.md)** — the formal exception contract.` + +- [ ] **Step 4: `docs/resilience.md`** + + Find: `- **`architecture/middleware.md`** — the formal Middleware/Seam-A contract.` + Replace with: `- **[`architecture/middleware.md`](https://github.com/modern-python/httpware/blob/main/architecture/middleware.md)** — the formal Middleware/Seam-A contract.` + +- [ ] **Step 5: `docs/testing.md`** + + Find: `- **`architecture/testing.md`** — the project's own testing patterns (Hypothesis property-based tests, `pytest-asyncio` auto-mode, the `RecordedTransport`-was-removed history).` + Replace with: `- **[`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).` + +- [ ] **Step 6: Verify no bare architecture path remains in published pages** + + Run: `grep -rnE '`architecture/[a-z]+\.md`' docs/*.md | grep -v 'github.com'` + Expected: no matches. + +- [ ] **Step 7: Commit** + + ```bash + git add docs/middleware.md docs/errors.md docs/resilience.md docs/testing.md + git commit -m "docs: link bare architecture/*.md references to GitHub source + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 7: Verify the whole change, update audit + Index, commit + +**Files:** +- Modify: `planning/audits/2026-06-13-docs-audit.md` (mark G1/G3/G4/nav resolved, G2 won't-do) +- Modify: `planning/README.md` (add the bundle to the Index "Active") + +- [ ] **Step 1: Strict docs build** + + ```bash + uv run --with-requirements docs/requirements.txt mkdocs build --strict && rm -rf site + ``` + Expected: `Documentation built in …s` with no warnings/errors. + +- [ ] **Step 2: Run the README quickstart for real** + + ```bash + uv run --with pydantic python - <<'PY' + import asyncio + from httpware import AsyncClient + from pydantic import BaseModel + + class User(BaseModel): + id: int + name: str + + async def main() -> None: + async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client: + print(await client.get("/users/1", response_model=User)) + + asyncio.run(main()) + PY + ``` + Expected: `id=1 name='Leanne Graham'` (requires network). + +- [ ] **Step 3: Lint regression check** + + Run: `just lint` + Expected: all checks pass (no source touched). + +- [ ] **Step 4: Mark the audit findings resolved** + + In `planning/audits/2026-06-13-docs-audit.md`, under the onboarding/UX list, append `**Resolved** (2026-06-14.01)` to **G1**, **G3**, **G4**, and the navigation-nits paragraph. For **G2**, append: `**Won't do** (2026-06-14.01) — base-client scrubbed from CLAUDE.md; no migration guide.` Leave **G6** open. + +- [ ] **Step 5: Add the bundle to the planning Index** + + In `planning/README.md`, under `### Active`, replace `_None._` with: + + ```markdown + - **[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. + ``` + +- [ ] **Step 6: Commit** + + ```bash + git add planning/audits/2026-06-13-docs-audit.md planning/README.md + git commit -m "docs(planning): mark docs-UX findings resolved + index the bundle + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +- [ ] **Step 7: Push and open the PR** + + ```bash + git push -u origin docs/ux-restructure + gh pr create --base main --head docs/ux-restructure \ + --title "docs: UX restructure — thin README, canonical site, runnable example" \ + --body "Closes audit findings G1, G3, G4, nav nits; G2 dropped (base-client scrubbed). See planning/changes/active/2026-06-14.01-docs-ux-restructure/. mkdocs --strict + live example run + just lint all green." + ``` + +--- + +## Notes for the executor + +- **Don't touch `docs/testing.md`'s `example.test`** — those are `MockTransport` + examples; the host is never dialed. +- **README links must be absolute** (`https://httpware.modern-python.org/…`) — PyPI + renders the README without mkdocs, so relative `docs/…` links 404 there. +- The `architecture/*.md` files are **not** part of the mkdocs build; that's why + they're linked to GitHub, not cross-linked in-site.