diff --git a/README.md b/README.md index 3dfbfbe..5df1d34 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ **Async HTTP client framework for Python.** -`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. +`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 small resilience suite — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`. -> **Status:** Pre-1.0 (0.3.0). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped. +> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped. ## Install @@ -42,7 +42,30 @@ async def main() -> None: print(user.name) ``` -## 📚 [Documentation](https://httpware.readthedocs.io) +### With resilience middleware + +Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts. + +```python +from httpware import AsyncClient, Bulkhead, Retry + + +async def main() -> None: + async with AsyncClient( + base_url="https://api.example.com", + middleware=[ + Bulkhead(max_concurrent=10), # cap total in-flight + Retry(), # default: 3 attempts, full-jitter backoff + ], + ) as client: + user = await client.get("/users/1", response_model=User) +``` + +## 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` and `BulkheadFullError`. Everything inherits `httpware.ClientError`. + +## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) ## 📦 [PyPI](https://pypi.org/project/httpware) diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index af4029e..5fc9ca5 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -31,11 +31,11 @@ just test # pytest with coverage These are enforced by CI grep gates. Do not break them in pull requests: -- No `import httpx2` outside `src/httpware/transports/httpx2.py`. - No `httpx2._*` (private API) usage anywhere in the library. - No `from __future__ import annotations`. - No `print()` calls. - No `logging.basicConfig()` or bare `logging.getLogger()`. +- Type suppressions use `# ty: ignore[]`, never `# type: ignore` or `# mypy: ignore`. ## Code of Conduct diff --git a/docs/index.md b/docs/index.md index 2e0a51b..70f0e0f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,8 @@ # httpware -A Python async HTTP client framework for building resilient service clients. `httpware` owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport directly. +A Python async HTTP client framework 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: `Retry` + `RetryBudget`, `Bulkhead`), opt-in typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx. -> **Status:** Pre-1.0 (0.1.0 alpha). Public API is subject to change between minor releases until v1.0. +> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped. ## Install @@ -13,6 +13,7 @@ pip install httpware Optional extras: ```bash +pip install httpware[pydantic] # PydanticDecoder (the default decoder path) pip install httpware[msgspec] # MsgspecDecoder ``` @@ -39,10 +40,34 @@ async def main() -> None: asyncio.run(main()) ``` +### With resilience middleware + +Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts. + +```python +from httpware import AsyncClient, Bulkhead, Retry + + +async def main() -> None: + async with AsyncClient( + base_url="https://api.example.com", + middleware=[ + Bulkhead(max_concurrent=10), # cap total in-flight + Retry(), # default: 3 attempts, full-jitter backoff + ], + ) as client: + user = await client.get("/users/1", response_model=User) +``` + +## 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` and `BulkheadFullError`. Everything inherits `httpware.ClientError`. + ## Where to go next -- **[Engineering Notes](dev/engineering.md)** — design invariants, the five protocol seams, exception contract, module layout, testing patterns, optional-extras pattern. +- **[Engineering Notes](https://github.com/modern-python/httpware/blob/main/planning/engineering.md)** — design invariants, the three protocol seams, exception contract, module layout, testing patterns, optional-extras pattern. Lives in the repo at `planning/engineering.md`. - **[Contributing](dev/contributing.md)** — setup, conventions, workflow. +- **[Release notes](https://github.com/modern-python/httpware/releases)** — per-version changelogs. ## Part of `modern-python` diff --git a/mkdocs.yml b/mkdocs.yml index 0cc2299..cf0d0aa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,7 +7,6 @@ edit_uri: edit/main/docs/ nav: - Quick-Start: index.md - Development: - - Engineering Notes: dev/engineering.md - Contributing: dev/contributing.md theme: diff --git a/planning/engineering.md b/planning/engineering.md index bece5d2..444da81 100644 --- a/planning/engineering.md +++ b/planning/engineering.md @@ -4,7 +4,7 @@ This doc is the single distilled reference for `httpware` design rationale, prot ## 1. Project intent -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. `AsyncClient(decoder=None)` defaults to constructing a `PydanticDecoder` and so requires the `pydantic` extra; callers can supply an explicit `decoder=` argument to escape the default. +`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. `AsyncClient(decoder=None)` defaults to constructing a `PydanticDecoder` and so requires the `pydantic` extra; callers can supply an explicit `decoder=` argument to escape the default. As of 0.4.0, the package ships a small resilience suite under `httpware.middleware.resilience` — a `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — composed via the standard middleware chain. The 0.1.0 release attempted to own a full abstraction over the underlying HTTP client. v0.2 walks that back: `httpx2` is part of the public surface. @@ -70,10 +70,16 @@ src/httpware/ ├── __init__.py # public exports ├── py.typed ├── client.py # AsyncClient -├── errors.py # status-keyed exception tree (response: httpx2.Response) +├── errors.py # status-keyed exception tree + NetworkError + RetryBudgetExhaustedError + BulkheadFullError ├── middleware/ │ ├── __init__.py # Middleware protocol, Next type, @before_request/@after_response/@on_error -│ └── chain.py # compose(middleware, terminal) -> Next +│ ├── chain.py # compose(middleware, terminal) -> Next +│ └── resilience/ +│ ├── __init__.py # re-exports Bulkhead, Retry, RetryBudget +│ ├── bulkhead.py # Bulkhead middleware (concurrency limiter) +│ ├── budget.py # RetryBudget (Finagle-style token bucket) +│ ├── retry.py # Retry middleware +│ └── _backoff.py # full-jitter exponential backoff helper (private) ├── decoders/ │ ├── __init__.py # ResponseDecoder protocol │ ├── pydantic.py # PydanticDecoder (extra: pydantic)