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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
129 changes: 25 additions & 104 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,52 +14,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`, 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.

## Install

```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

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

Expand Down
2 changes: 1 addition & 1 deletion docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 10 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -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())
```

Expand Down
4 changes: 2 additions & 2 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
26 changes: 17 additions & 9 deletions planning/audits/2026-06-13-docs-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
Empty file.
Loading