diff --git a/README.md b/README.md index 5e8862c..6ba10f2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ async def main() -> None: user = await client.get("/users/1", response_model=User) ``` +Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](docs/middleware.md). + ### Streaming responses For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager: diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..1ab6836 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,133 @@ +# Errors reference + +`httpware` raises typed exceptions automatically — everything inherits `ClientError`, and HTTP responses with 4xx/5xx status raise status-keyed `StatusError` subclasses without you having to call `response.raise_for_status()`. + +For the resilience-specific errors (`RetryBudgetExhaustedError`, `BulkheadFullError`) see the [Resilience reference](resilience.md). + +## The exception tree + +``` +ClientError (catch-all for anything httpware raises) +├── TransportError (connection/network/protocol failure pre-response) +│ └── NetworkError (transient — safe to retry; covered by Retry's defaults) +├── TimeoutError (also inherits builtins.TimeoutError — except OSError catches it) +├── StatusError (got a response but its status was 4xx/5xx) +│ ├── ClientStatusError (any 4xx — fallback for unknown 4xx codes) +│ │ ├── BadRequestError (400) +│ │ ├── UnauthorizedError (401) +│ │ ├── ForbiddenError (403) +│ │ ├── NotFoundError (404) +│ │ ├── ConflictError (409) +│ │ ├── UnprocessableEntityError (422) +│ │ └── RateLimitedError (429) +│ └── ServerStatusError (any 5xx — fallback for unknown 5xx codes) +│ ├── InternalServerError (500) +│ └── ServiceUnavailableError (503) +├── RetryBudgetExhaustedError (a retry was needed but the budget refused) +└── BulkheadFullError (acquire_timeout elapsed before a slot opened) +``` + +## Status-to-exception mapping + +| Status | Exception class | +|---|---| +| 400 | `BadRequestError` | +| 401 | `UnauthorizedError` | +| 403 | `ForbiddenError` | +| 404 | `NotFoundError` | +| 409 | `ConflictError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitedError` | +| 500 | `InternalServerError` | +| 503 | `ServiceUnavailableError` | +| other 4xx | `ClientStatusError` (fallback) | +| other 5xx | `ServerStatusError` (fallback) | + +The fallback assumes `400 ≤ status < 600`. Statuses outside that range don't raise (they return the response as-is). + +## Catching strategies + +```python +from httpware import ( + AsyncClient, + ClientError, + StatusError, + NetworkError, + TimeoutError, + NotFoundError, + RetryBudgetExhaustedError, + BulkheadFullError, +) + + +async def fetch(client: AsyncClient, user_id: int) -> dict | None: + try: + return await client.get(f"/users/{user_id}", response_model=dict) + except NotFoundError: + # Specific status — most precise. Convert to None as the "absent" sentinel. + return None + except StatusError as exc: + # Got a response, but its status was 4xx/5xx and not one we handle specifically. + # exc.response.* is available — headers, content, request, etc. + _LOGGER.warning("upstream returned %s for %s", exc.response.status_code, exc.response.request.url) + raise + except NetworkError: + # Transient transport failure. Already retried by the default Retry middleware + # (if installed) when the method was idempotent. Seeing this means retries + # exhausted or the method was non-idempotent. + raise + except (RetryBudgetExhaustedError, BulkheadFullError) as exc: + # Resilience refusal — backpressure signal. Back off the caller. + _LOGGER.error("resilience refused: %s", exc) + raise + except ClientError: + # Catch-all for anything else httpware raised. + raise +``` + +`TimeoutError` is doubly-inherited: `except builtins.TimeoutError` and `except OSError` both catch it (matches what `asyncio.wait_for` raises). This lets stdlib-style timeout handling Just Work. + +## `exc.response.*` access pattern + +For any `StatusError` subclass, the raw `httpx2.Response` is on `exc.response`: + +```python +exc.response.status_code # 404 +exc.response.headers # httpx2.Headers — case-insensitive +exc.response.content # raw bytes +exc.response.text # decoded body +exc.response.json() # parsed JSON (raises if not JSON) +exc.response.request # the failing httpx2.Request +exc.response.request.url # the failing URL (httpx2.URL) +exc.response.request.method # the HTTP method +``` + +**Security note:** `__repr__` and the exception's summary message strip `user:pass@` userinfo from the URL to avoid leaking credentials in tracebacks. **Query-string secrets are NOT stripped** — keep secrets out of query strings. + +## Resilience-error payloads + +`RetryBudgetExhaustedError` carries: +- `last_response: httpx2.Response | None` — the last response observed before the budget refused (None if all failures were transport-level) +- `last_exception: BaseException | None` — the last exception observed before the budget refused +- `attempts: int` — number of attempts already completed + +`BulkheadFullError` carries: +- `max_concurrent: int` — the configured cap +- `acquire_timeout: float | None` — the configured timeout + +Use these for caller-side logging / alerting: + +```python +except RetryBudgetExhaustedError as exc: + _LOGGER.error( + "budget exhausted after %d attempts; last_status=%s", + exc.attempts, + exc.last_response.status_code if exc.last_response is not None else None, + ) +``` + +## See also + +- **[Resilience reference](resilience.md)** — `Retry`, `RetryBudget`, `Bulkhead` parameter tables. +- **[Middleware guide](middleware.md)** — the `@on_error` decorator can translate exceptions into responses. +- **`planning/engineering.md` §4** — the formal exception contract. diff --git a/docs/index.md b/docs/index.md index 0e50f29..e10a5c9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,6 +106,10 @@ When installed, `_emit_event` calls `trace.get_current_span().add_event(name, at ## Where to go next +- **[Resilience reference](resilience.md)** — every parameter on `Retry`, `RetryBudget`, and `Bulkhead`; the retry-rule matrix; Retry-After parsing; budget sharing. +- **[Middleware guide](middleware.md)** — write your own middleware. Covers the Middleware Protocol, the phase decorators, a worked Request-ID propagation example, and OpenTelemetry wiring. +- **[Errors reference](errors.md)** — the full exception tree, catching strategies, `exc.response.*` access pattern. +- **[Testing guide](testing.md)** — mock-transport injection pattern for testing code that uses `httpware`. - **[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. diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..94db1d0 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,159 @@ +# Writing custom middleware + +`httpware`'s primary extension point is the **Middleware protocol**. Middleware lets you add cross-cutting behavior — request-ID propagation, auth header injection, structured tracing, custom resilience policies, anything that wraps "send a request, get a response" — without subclassing `AsyncClient` or touching the transport. + +The built-in `Retry` and `Bulkhead` middleware are themselves implementations of this protocol; nothing about them is privileged. If you want a circuit breaker, a rate limiter, or a header-injecting auth layer, write a middleware. If your need is per-call (not cross-cutting), pass it through `request.extensions=` instead. + +## The protocol + +Two symbols, both exported from `httpware.middleware`: + +```python +from collections.abc import Awaitable, Callable +from typing import Protocol, TypeAlias, runtime_checkable +import httpx2 + +Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + + +@runtime_checkable +class Middleware(Protocol): + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: ... +``` + +The chain is composed once at `AsyncClient.__init__` and frozen for the client's lifetime. The first entry in `middleware=[...]` is the outermost layer: when you write `middleware=[Bulkhead(...), Retry()]`, the bulkhead sees every request before the retry layer does, so one slot covers all retry attempts of the same call. + +Calling `await next(request)` forwards to the next layer (or, eventually, to the terminal that hits `httpx2`). You can: + +- **Forward unchanged:** `return await next(request)` +- **Modify the request first:** mutate `request.headers` (or build a replacement) before forwarding +- **Inspect or replace the response:** call `await next(...)`, then act on what comes back +- **Short-circuit:** return a synthesized `httpx2.Response` without calling `next` at all +- **Wrap the call in error handling:** `try: return await next(...) except ...` to translate failures + +Whatever you do, return an `httpx2.Response`. Raising an exception propagates up the chain (Retry catches retryable exceptions; everything else surfaces to the caller). + +## Phase decorators + +For the common cases where you don't need state-keeping on `self` and don't need to wrap the full `await next(...)` call, `httpware.middleware` exports three decorators that turn a single async function into a `Middleware`: + +```python +from httpware.middleware import before_request, after_response, on_error +``` + +| Decorator | Function signature | When to use | +|---|---|---| +| `@before_request` | `async (request) -> request` | Transform the outgoing request (add a header, rewrite a URL). | +| `@after_response` | `async (request, response) -> response` | Transform the incoming response (decode, log, attach metadata). | +| `@on_error` | `async (request, exc) -> response \| None` | Translate or absorb a failure. Return `None` to re-raise. Catches `Exception` (not `BaseException`), so `asyncio.CancelledError` propagates. | + +Brief example — adding an `Authorization` header before every request: + +```python +import httpx2 + +from httpware import AsyncClient +from httpware.middleware import before_request + + +@before_request +async def add_bearer(request: httpx2.Request) -> httpx2.Request: + request.headers["Authorization"] = "Bearer secret-token" + return request + + +async def main() -> None: + async with AsyncClient(base_url="https://api.example.com", middleware=[add_bearer]) as client: + await client.get("/me") +``` + +**Reach for the raw `Middleware` protocol when:** you need instance state (a counter, a CircuitBreaker's open/closed flag), you need to inspect both the request AND its response (e.g., timing), or you need to interleave behavior around the `await next(...)` call (e.g., emit one log line at the start and one at the end). The decorators are a convenience for the cases where a single function suffices. + +## Worked example: request-ID propagation + +A `RequestIdMiddleware` that assigns a per-call UUID, injects it as an outgoing header, and logs it alongside the response status. This is the canonical "trace every request through your distributed system" pattern. + +```python +import logging +import uuid + +import httpx2 + +from httpware import AsyncClient, Retry +from httpware.middleware import Next + + +_LOGGER = logging.getLogger("myapp.request_id") + + +class RequestIdMiddleware: + """Assign a per-call X-Request-Id; log it on response. + + Place OUTSIDE Retry so all attempts of the same call share one ID + (so a single call's retries all surface under the same correlation + key in your logs, and match the URL attribute on httpware.retry's + emitted events). + """ + + def __init__(self, *, header: str = "X-Request-Id") -> None: + self._header = header + + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + request_id = str(uuid.uuid4()) + request.headers[self._header] = request_id + response = await next(request) + _LOGGER.info( + "request complete", + extra={"request_id": request_id, "status": response.status_code}, + ) + return response + + +async def main() -> None: + async with AsyncClient( + base_url="https://api.example.com", + middleware=[RequestIdMiddleware(), Retry()], # ID outside Retry + ) as client: + await client.get("/users/1") +``` + +A note on logger names: the example logs under `myapp.request_id`, NOT under `httpware.*`. The `httpware.*` namespace is reserved for events emitted by the library itself (see [Observability](index.md#observability) — `httpware.retry` and `httpware.bulkhead` are stable contracts). Consumer middleware should use your application's own logger namespace. + +The example pairs naturally with the 0.6.0 observability events: a `httpware.retry` `retry.giving_up` log record carries a `url` attribute, and your `RequestIdMiddleware` set an `X-Request-Id` for that same call. Correlate the two in your log aggregator and you have end-to-end visibility from "this user's request" to "we gave up after N retries." + +## When NOT to write a middleware + +- **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 `planning/engineering.md` §8. + +## Wiring OpenTelemetry + +`httpware[otel]` only ships `opentelemetry-api`. To make the observability events emitted by `Retry` and `Bulkhead` visible, you also need: + +- An **SDK** (`opentelemetry-sdk`) to actually collect spans +- An **HTTP instrumentor** (`opentelemetry-instrumentation-httpx`) so each HTTP call creates a span — `httpware`'s events attach to that span via `trace.get_current_span().add_event(...)` + +Minimal setup (console exporter for development): + +```python +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) +HTTPXClientInstrumentor().instrument() +``` + +After this runs, every `httpware` HTTP call gets an `HTTP ` span from the instrumentor, and Retry/Bulkhead observability events appear as span events on it (no extra configuration needed in `httpware` itself — the events fire whenever an active span is present). + +For production, swap `ConsoleSpanExporter` for your OTLP/Jaeger/Zipkin exporter. See the [OpenTelemetry Python docs](https://opentelemetry.io/docs/languages/python/) for the full SDK setup. + +## See also + +- **`planning/engineering.md` §3 (Seam A)** — the formal protocol contract and why the chain is frozen at construction. +- **`src/httpware/middleware/resilience/`** — `Retry`, `Bulkhead`, `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 new file mode 100644 index 0000000..7c1074e --- /dev/null +++ b/docs/resilience.md @@ -0,0 +1,173 @@ +# Resilience reference + +`httpware` ships three resilience primitives under `httpware.middleware.resilience`, all composable through the standard [Middleware](middleware.md) chain: + +- **`Retry`** — automatic retry of transient failures with full-jitter exponential backoff +- **`RetryBudget`** — Finagle-style token bucket that bounds the global retry rate to prevent retry storms when downstreams degrade +- **`Bulkhead`** — concurrency limiter via `asyncio.Semaphore` with bounded acquire-wait + +The canonical composition is `middleware=[Bulkhead(...), Retry()]` — `Bulkhead` outside `Retry` so one slot covers all retry attempts of a single call. Reach for the [Middleware guide](middleware.md) when you want to write your own resilience policy. + +## `Retry` + +```python +from httpware.middleware.resilience import Retry +``` + +| Parameter | Default | Effect | +|---|---|---| +| `max_attempts` | `3` | Total tries (including the first). `1` disables retries entirely; `<1` raises `ValueError`. | +| `base_delay` | `0.1` (s) | Floor for the full-jitter exponential backoff. | +| `max_delay` | `5.0` (s) | Ceiling for backoff. | +| `attempt_timeout` | `None` | If set, each individual attempt is wrapped in `asyncio.timeout(attempt_timeout)`. | +| `retry_status_codes` | `frozenset({408, 429, 502, 503, 504})` | Status codes considered retryable. | +| `retry_methods` | `frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})` | Idempotent methods only by default. POST excluded; pass an explicit frozenset including `"POST"` to retry it. | +| `respect_retry_after` | `True` | When the response carries a `Retry-After` header on a retryable status, sleep for the header value (clamped to `max_delay`) instead of the jittered backoff. | +| `budget` | `RetryBudget()` (default-configured) | The token bucket. Pass a shared `RetryBudget` instance to apply one budget across multiple clients. | + +### Retry-After parsing + +`Retry-After` is parsed as either: +- **Integer seconds** — `Retry-After: 30` → sleep 30s (clamped to `max_delay`) +- **HTTP-date** (RFC 5322) — `Retry-After: Wed, 21 Oct 2026 07:28:00 GMT` → sleep until that absolute time (clamped to `max_delay`, floored at 0) + +Negative integer values are clamped to 0. Malformed values are ignored, falling back to the jittered backoff. + +### Streaming-body refusal + +If the request body was an async-iterable, `Retry` refuses to retry — the iterator is consumed after the first attempt and can't replay. The original exception is re-raised with a PEP 678 note: + +``` +httpware: not retrying — request body is a stream that cannot replay across attempts +``` + +The same refusal note is added at the non-idempotent early-exit sites (when streaming combines with a non-idempotent method). The observability event `httpware.retry` `retry.streaming_refused` fires only at the retryable-failure-path site — see [Observability](index.md#observability). + +### Exhaustion behavior + +On exhaustion, `Retry` re-raises the *last* exception observed (e.g., `ServiceUnavailableError`, `NetworkError`), preserving the original class so `except ServiceUnavailableError` still catches it. A PEP 678 note is added: `httpware: gave up after N attempts`. + +If exhaustion is caused by the budget refusing a retry (not by `max_attempts`), the raised exception is `RetryBudgetExhaustedError` instead, with `last_response` / `last_exception` / `attempts` fields populated. See the [Errors reference](errors.md). + +## `RetryBudget` + +```python +from httpware.middleware.resilience import RetryBudget +``` + +A Finagle-style token bucket bounding retry rate. Each request deposits a token; each retry attempts to withdraw one. Available retries are bounded by `percent_can_retry` of recent deposits, plus a `min_retries_per_sec * ttl` floor. + +| Parameter | Default | Effect | +|---|---|---| +| `ttl` | `10.0` (s) | Sliding window over which deposits and withdrawals count. | +| `min_retries_per_sec` | `10.0` | Absolute floor — at least this many retries/sec are permitted regardless of deposit rate. | +| `percent_can_retry` | `0.2` | Fraction of recent deposits that can convert to retries (above the floor). | + +### The token-bucket formula + +``` +ceiling = int(len(deposits_in_window) * percent_can_retry) + int(min_retries_per_sec * ttl) +``` + +A withdrawal fails when `len(withdrawn_in_window) >= ceiling`. + +### Why a floor matters + +If the deposit rate is zero (no traffic yet), the percent term is zero — without the floor, the very first retry would be refused. The floor lets small-traffic clients still retry on isolated failures; high-traffic clients are dominated by the percent term and the floor becomes irrelevant. + +### Sharing across clients + +Pass the same `RetryBudget` instance to multiple `AsyncClient`s when they hit the same downstream — one joint budget covers them all: + +```python +import asyncio + +from httpware import AsyncClient +from httpware.middleware.resilience import Retry, RetryBudget + + +shared = RetryBudget() + + +async def main() -> None: + async with ( + AsyncClient(base_url="https://api.example.com", middleware=[Retry(budget=shared)]) as users, + AsyncClient(base_url="https://api.example.com", middleware=[Retry(budget=shared)]) as orders, + ): + await asyncio.gather(users.get("/users/1"), orders.get("/orders/1")) +``` + +### Single-thread assumption + +`RetryBudget` is asyncio-aware — deque mutations between await points are atomic on a single event loop. Cross-thread use is out of scope; if you need that, wrap calls in a lock yourself. + +## `Bulkhead` + +```python +from httpware.middleware.resilience import Bulkhead +``` + +Concurrency limiter via `asyncio.Semaphore`. Acquires a slot before each request (bounded by `acquire_timeout`); releases on success, exception, AND cancellation. + +| Parameter | Default | Effect | +|---|---|---| +| `max_concurrent` | **REQUIRED** | Maximum in-flight requests. `<1` raises `ValueError`. No default — the right cap depends on downstream capacity and SLA. | +| `acquire_timeout` | `1.0` (s) | How long to wait for a slot before raising `BulkheadFullError`. `None` waits forever; `0` fails fast. `<0` raises `ValueError`. | + +### Slot release contract + +The slot is released in a `try/finally` around `await next(request)`, so all three exit paths release deterministically: +- **Success** — slot released after the response returns +- **Exception** — slot released before the exception propagates +- **Cancellation** — slot released as the `CancelledError` propagates + +The slot cannot leak. + +### Sharing across clients + +Same pattern as `RetryBudget`. One instance, many clients: + +```python +shared_bulkhead = Bulkhead(max_concurrent=10) + +async with ( + AsyncClient(base_url="https://api.example.com", middleware=[shared_bulkhead, Retry()]) as a, + AsyncClient(base_url="https://api.example.com", middleware=[shared_bulkhead, Retry()]) as b, +): + ... # combined in-flight across a + b is capped at 10 +``` + +### Rejection + +When `acquire_timeout` elapses without a slot opening, `Bulkhead` raises `BulkheadFullError` (carries the configured `max_concurrent` and `acquire_timeout` for caller logging). See the [Errors reference](errors.md). The `httpware.bulkhead` `bulkhead.rejected` observability event fires at the same site — see [Observability](index.md#observability). + +## Composition + +The canonical ordering is `middleware=[Bulkhead, Retry]` — `Bulkhead` outermost so one slot covers all retry attempts of a single call: + +```python +from httpware import AsyncClient +from httpware.middleware.resilience import Bulkhead, Retry + + +async def main() -> None: + async with AsyncClient( + base_url="https://api.example.com", + middleware=[ + Bulkhead(max_concurrent=10), + Retry(), + ], + ) as client: + await client.get("/users/1") +``` + +Flipping the order (`[Retry, Bulkhead]`) means each retry attempt grabs a fresh slot — defeating the bulkhead under load. Don't do that. + +Cross-cutting middleware that emit per-call state (e.g., the Request-ID middleware in the [Middleware guide](middleware.md)) should sit outside `Retry` for the same reason — so all attempts of one call share one ID rather than getting a fresh ID per attempt. + +## See also + +- **[Middleware guide](middleware.md)** — write your own resilience middleware against the same protocol `Retry` and `Bulkhead` use. +- **[Errors reference](errors.md)** — `RetryBudgetExhaustedError`, `BulkheadFullError`, and the broader exception tree. +- **[Observability](index.md#observability)** — the four operational events these middleware emit. +- **`planning/engineering.md` §3** — the formal Middleware/Seam-A contract. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..79cda44 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,88 @@ +# Testing guide + +`httpware`'s test seam is `httpx2`. Pass any `httpx2.AsyncClient` (including one built on `httpx2.MockTransport`) to `AsyncClient(httpx2_client=...)` — the middleware chain still runs end-to-end, only the wire is mocked. No special test mode, no monkey-patching, no `respx`. + +## The basic pattern + +```python +from http import HTTPStatus + +import httpx2 + +from httpware import AsyncClient + + +def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, json={"id": 1, "name": "Alice"}) + + +async def test_get_user() -> None: + transport = httpx2.MockTransport(handler) + async with AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) as client: + response = await client.get("https://api.example.test/users/1") + assert response.status_code == HTTPStatus.OK + assert response.json()["name"] == "Alice" +``` + +The handler can be sync or async; `httpx2.MockTransport` supports both. The test above uses a sync handler. + +If you use `pytest-asyncio` in auto-mode (`asyncio_mode = "auto"` under `[tool.pytest.ini_options]`), async test functions don't need the `@pytest.mark.asyncio` decorator. + +## Recording / stateful handlers + +For tests that need to vary the response by call count or assert on the requests that came in, use a handler with instance state: + +```python +class _ResponseSequence: + """Returns each status in order; records every request received.""" + + def __init__(self, statuses: list[int]) -> None: + self._statuses = list(statuses) + self.calls: list[httpx2.Request] = [] + + def __call__(self, request: httpx2.Request) -> httpx2.Response: + self.calls.append(request) + status = self._statuses.pop(0) if self._statuses else HTTPStatus.OK + return httpx2.Response(status, request=request) + + +async def test_retry_succeeds_after_503() -> None: + handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK]) + transport = httpx2.MockTransport(handler) + async with AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=transport), + middleware=[Retry(base_delay=0.001, max_delay=0.002)], + ) as client: + response = await client.get("https://example.test/x") + assert response.status_code == HTTPStatus.OK + assert len(handler.calls) == 2 # initial + 1 retry +``` + +The `base_delay`/`max_delay` are set tiny so the test runs instantly — no need for `freezegun` or sleep injection in most cases. + +## Testing your custom middleware + +Compose your middleware with the mock transport to exercise the chain end-to-end: + +```python +async def test_my_middleware_adds_header() -> None: + handler = _ResponseSequence([HTTPStatus.OK]) + async with AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + middleware=[MyHeaderMiddleware()], + ) as client: + await client.get("https://example.test/x") + assert handler.calls[0].headers["X-My-Header"] == "expected-value" +``` + +For middleware with state-keeping (counters, circuit-breaker state), assert on instance attributes after running the call. + +## Why not `respx`? + +`httpware` deliberately uses `httpx2.MockTransport` instead of `respx` for its own tests. `MockTransport` is the public test seam in `httpx` — supported by the maintainers, stable across versions, lives in the public API surface. `respx` patches private internals and has historically broken across `httpx` major versions. Stick with `MockTransport` unless you have a specific reason not to. + +## See also + +- **[Middleware guide](middleware.md)** — write the middleware you're testing. +- **[Resilience reference](resilience.md)** — testing `Retry`/`Bulkhead` configurations. +- **`planning/engineering.md` §6** — 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 cf0d0aa..e9afd1f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,10 @@ edit_uri: edit/main/docs/ nav: - Quick-Start: index.md + - Resilience: resilience.md + - Middleware: middleware.md + - Errors: errors.md + - Testing: testing.md - Development: - Contributing: dev/contributing.md diff --git a/planning/engineering.md b/planning/engineering.md index 6d80123..70c8dc9 100644 --- a/planning/engineering.md +++ b/planning/engineering.md @@ -131,7 +131,9 @@ Post-pivot, the roadmap has three categories. Topic slugs in `planning/specs/` a - **Epic 3 — Resilience:** - **Shipped in v0.4 slice 1:** `Retry` middleware + Finagle-style `RetryBudget` token bucket + `attempt_timeout=` parameter (folded-in 3-1). See [`planning/specs/2026-06-05-retry-and-retry-budget-design.md`](specs/2026-06-05-retry-and-retry-budget-design.md) and [`planning/plans/2026-06-05-retry-and-retry-budget-plan.md`](plans/2026-06-05-retry-and-retry-budget-plan.md). - **Shipped in v0.4 slice 2:** `Bulkhead` middleware (concurrency limiter via `asyncio.Semaphore` with bounded acquire wait). See [`planning/specs/2026-06-05-bulkhead-design.md`](specs/2026-06-05-bulkhead-design.md) and [`planning/plans/2026-06-05-bulkhead-plan.md`](plans/2026-06-05-bulkhead-plan.md). - - **Remaining:** `3-6` extension-slot docs. + - **Shipped in v0.7:** `3-6` extension-slot docs — [`docs/middleware.md`](../docs/middleware.md). Covers the Middleware Protocol, phase decorators, a Request-ID worked example, and "when NOT to write a middleware." See [`planning/specs/2026-06-05-extension-slot-docs-design.md`](specs/2026-06-05-extension-slot-docs-design.md) and [`planning/plans/2026-06-05-extension-slot-docs-plan.md`](plans/2026-06-05-extension-slot-docs-plan.md). + - **v0.7 also bundles** the rest of the first-cut user docs surface — [`docs/resilience.md`](../docs/resilience.md) (Retry/RetryBudget/Bulkhead reference), [`docs/errors.md`](../docs/errors.md) (exception tree + catching strategies), [`docs/testing.md`](../docs/testing.md) (mock-transport injection pattern) — plus an "OpenTelemetry wiring" section appended to `docs/middleware.md`. See [`planning/specs/2026-06-05-v0.7-docs-expansion-design.md`](specs/2026-06-05-v0.7-docs-expansion-design.md) and [`planning/plans/2026-06-05-v0.7-docs-expansion-plan.md`](plans/2026-06-05-v0.7-docs-expansion-plan.md). + - **Epic 3 closed.** - **Epic 4 — Streaming:** SHIPPED in v0.5 (PR #…): `AsyncClient.stream()` context manager + Retry refuses streamed-body requests. See [`planning/specs/2026-06-05-streaming-design.md`](specs/2026-06-05-streaming-design.md) and [`planning/plans/2026-06-05-streaming-plan.md`](plans/2026-06-05-streaming-plan.md). - **Epic 5 — Observability:** SHIPPED in v0.6 (PR #…) — re-scoped from the original 4-story plan. `Retry` and `Bulkhead` emit operational events via stdlib `logging` + opt-in OpenTelemetry span events. Stories `5-1` (Layer 1 middleware hooks) and `5-4` (standalone OTel middleware) RETIRED — `opentelemetry-instrumentation-httpx` already covers transport-level tracing; a separate httpware middleware would duplicate it. See [`planning/specs/2026-06-05-observability-design.md`](specs/2026-06-05-observability-design.md) and [`planning/plans/2026-06-05-observability-plan.md`](plans/2026-06-05-observability-plan.md). - **Epic 6 — Ship v1.0:** `6-2` docs site (`mkdocs`), `6-3` benchmarks, `6-5` release flow (Trusted Publishers + Sigstore). diff --git a/planning/plans/2026-06-05-extension-slot-docs-plan.md b/planning/plans/2026-06-05-extension-slot-docs-plan.md new file mode 100644 index 0000000..5854561 --- /dev/null +++ b/planning/plans/2026-06-05-extension-slot-docs-plan.md @@ -0,0 +1,532 @@ +# Extension-slot docs (0.7.0, Epic 3 story 3-6) 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:** Ship `docs/middleware.md` — a user-facing guide to writing custom middleware against `httpware`'s Middleware protocol — plus the four small touchups that hang off it (mkdocs nav, README pointer, docs/index pointer, engineering.md §8 SHIPPED line) and 0.7.0 release notes. Closes Epic 3. + +**Architecture:** Docs-only PR. One new markdown page (~150 lines), four small textual edits to existing files, one new release-notes file. No source code changes. Verification is `mkdocs build --strict` + link resolution + the existing test/lint suites as no-op confirmation. + +**Tech Stack:** Markdown, mkdocs-material (strict build), no source code. + +**Target branch:** `feat/v0.7-middleware-docs`. Create from `main` before Task 1: `git checkout main && git pull && git checkout -b feat/v0.7-middleware-docs`. + +**Source spec:** [`planning/specs/2026-06-05-extension-slot-docs-design.md`](../specs/2026-06-05-extension-slot-docs-design.md). Read the spec's "Background" + "Deliverable" sections before starting — the *why* for non-resilience example choice and Seam-A-only scope lives there. + +--- + +## File structure + +**New files:** +- `docs/middleware.md` — the guide itself (~150 lines) +- `planning/releases/0.7.0.md` — release notes + +**Modified files:** +- `mkdocs.yml` — add nav entry between Quick-Start and Development +- `README.md` — one-sentence pointer in the existing "With resilience middleware" subsection +- `docs/index.md` — one bullet in the existing "Where to go next" section +- `planning/engineering.md` §8 — replace the "**Remaining:** `3-6` extension-slot docs." line under Epic 3 + +**Commit cadence:** one commit per task. Per-task commits keep history reviewable. + +--- + +## Task 1: Branch + create `docs/middleware.md` + +**Files:** +- Create: `docs/middleware.md` + +- [ ] **Step 1: Create the branch** + +```bash +git checkout main && git pull && git checkout -b feat/v0.7-middleware-docs +``` +Expected: switched to a new branch. + +- [ ] **Step 2: Create `docs/middleware.md` with the full content below** + +````markdown +# Writing custom middleware + +`httpware`'s primary extension point is the **Middleware protocol**. Middleware lets you add cross-cutting behavior — request-ID propagation, auth header injection, structured tracing, custom resilience policies, anything that wraps "send a request, get a response" — without subclassing `AsyncClient` or touching the transport. + +The built-in `Retry` and `Bulkhead` middleware are themselves implementations of this protocol; nothing about them is privileged. If you want a circuit breaker, a rate limiter, or a header-injecting auth layer, write a middleware. If your need is per-call (not cross-cutting), pass it through `request.extensions=` instead. + +## The protocol + +Two symbols, both exported from `httpware.middleware`: + +```python +from collections.abc import Awaitable, Callable +from typing import Protocol, TypeAlias, runtime_checkable +import httpx2 + +Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] + + +@runtime_checkable +class Middleware(Protocol): + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: ... +``` + +The chain is composed once at `AsyncClient.__init__` and frozen for the client's lifetime. The first entry in `middleware=[...]` is the outermost layer: when you write `middleware=[Bulkhead(...), Retry()]`, the bulkhead sees every request before the retry layer does, so one slot covers all retry attempts of the same call. + +Calling `await next(request)` forwards to the next layer (or, eventually, to the terminal that hits `httpx2`). You can: + +- **Forward unchanged:** `return await next(request)` +- **Modify the request first:** mutate `request.headers` (or build a replacement) before forwarding +- **Inspect or replace the response:** call `await next(...)`, then act on what comes back +- **Short-circuit:** return a synthesized `httpx2.Response` without calling `next` at all +- **Wrap the call in error handling:** `try: return await next(...) except ...` to translate failures + +Whatever you do, return an `httpx2.Response`. Raising an exception propagates up the chain (Retry catches retryable exceptions; everything else surfaces to the caller). + +## Phase decorators + +For the common cases where you don't need state-keeping on `self` and don't need to wrap the full `await next(...)` call, `httpware.middleware` exports three decorators that turn a single async function into a `Middleware`: + +```python +from httpware.middleware import before_request, after_response, on_error +``` + +| Decorator | Function signature | When to use | +|---|---|---| +| `@before_request` | `async (request) -> request` | Transform the outgoing request (add a header, rewrite a URL). | +| `@after_response` | `async (request, response) -> response` | Transform the incoming response (decode, log, attach metadata). | +| `@on_error` | `async (request, exc) -> response \| None` | Translate or absorb a failure. Return `None` to re-raise. Catches `Exception` (not `BaseException`), so `asyncio.CancelledError` propagates. | + +Brief example — adding an `Authorization` header before every request: + +```python +import httpx2 + +from httpware import AsyncClient +from httpware.middleware import before_request + + +@before_request +async def add_bearer(request: httpx2.Request) -> httpx2.Request: + request.headers["Authorization"] = "Bearer secret-token" + return request + + +async def main() -> None: + async with AsyncClient(base_url="https://api.example.com", middleware=[add_bearer]) as client: + await client.get("/me") +``` + +**Reach for the raw `Middleware` protocol when:** you need instance state (a counter, a CircuitBreaker's open/closed flag), you need to inspect both the request AND its response (e.g., timing), or you need to interleave behavior around the `await next(...)` call (e.g., emit one log line at the start and one at the end). The decorators are a convenience for the cases where a single function suffices. + +## Worked example: request-ID propagation + +A `RequestIdMiddleware` that assigns a per-call UUID, injects it as an outgoing header, and logs it alongside the response status. This is the canonical "trace every request through your distributed system" pattern. + +```python +import logging +import uuid + +import httpx2 + +from httpware import AsyncClient, Retry +from httpware.middleware import Next + + +_LOGGER = logging.getLogger("myapp.request_id") + + +class RequestIdMiddleware: + """Assign a per-call X-Request-Id; log it on response. + + Place OUTSIDE Retry so all attempts of the same call share one ID + (so a single call's retries all surface under the same correlation + key in your logs, and match the URL attribute on httpware.retry's + emitted events). + """ + + def __init__(self, *, header: str = "X-Request-Id") -> None: + self._header = header + + async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 + request_id = str(uuid.uuid4()) + request.headers[self._header] = request_id + response = await next(request) + _LOGGER.info( + "request complete", + extra={"request_id": request_id, "status": response.status_code}, + ) + return response + + +async def main() -> None: + async with AsyncClient( + base_url="https://api.example.com", + middleware=[RequestIdMiddleware(), Retry()], # ID outside Retry + ) as client: + await client.get("/users/1") +``` + +A note on logger names: the example logs under `myapp.request_id`, NOT under `httpware.*`. The `httpware.*` namespace is reserved for events emitted by the library itself (see [Observability](index.md#observability) — `httpware.retry` and `httpware.bulkhead` are stable contracts). Consumer middleware should use your application's own logger namespace. + +The example pairs naturally with the 0.6.0 observability events: a `httpware.retry` `retry.giving_up` log record carries a `url` attribute, and your `RequestIdMiddleware` set an `X-Request-Id` for that same call. Correlate the two in your log aggregator and you have end-to-end visibility from "this user's request" to "we gave up after N retries." + +## When NOT to write a middleware + +- **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 `planning/engineering.md` §8. + +## See also + +- **`planning/engineering.md` §3 (Seam A)** — the formal protocol contract and why the chain is frozen at construction. +- **`src/httpware/middleware/resilience/`** — `Retry`, `Bulkhead`, `RetryBudget` as real-world consumers of this exact protocol. +- **[Quick-Start composition example](index.md#with-resilience-middleware)** — composing built-in middleware. +```` + +- [ ] **Step 3: Commit** + +```bash +git add docs/middleware.md +git commit -m "docs(middleware): write custom-middleware guide (3-6) + +New docs/middleware.md covering: +- The Middleware Protocol + Next type, exported from httpware.middleware +- Phase decorators (@before_request, @after_response, @on_error) as + ergonomic shortcuts for the no-state-keeping cases +- Worked example: a RequestIdMiddleware that assigns a per-call UUID + via X-Request-Id and logs it alongside the response status. Placed + outside Retry on purpose so all attempts of the same call share one + ID and correlate with the 0.6.0 observability events' url attribute +- 'When NOT to write a middleware' section covering redaction (use a + logging.Filter), URL/header validation (httpx2 owns it), per-call + behavior (use request.extensions=), and HTTP-tracing (install + opentelemetry-instrumentation-httpx instead) + +Closes the deferred-tutorial half of story 3-6. See spec at +planning/specs/2026-06-05-extension-slot-docs-design.md." +``` + +--- + +## Task 2: Add nav entry to `mkdocs.yml` + verify strict build + +**Files:** +- Modify: `mkdocs.yml` + +- [ ] **Step 1: Add nav entry** + +The current `nav:` block reads: +```yaml +nav: + - Quick-Start: index.md + - Development: + - Contributing: dev/contributing.md +``` + +Change to: +```yaml +nav: + - Quick-Start: index.md + - Middleware: middleware.md + - Development: + - Contributing: dev/contributing.md +``` + +- [ ] **Step 2: Verify mkdocs strict build is clean** + +```bash +uv run --with mkdocs --with mkdocs-material mkdocs build --strict 2>&1 | tail -20 +``` +Expected: `Documentation built in