Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9b3be17
docs(spec): extension-slot docs design (Epic 3 story 3-6)
lesnik512 Jun 5, 2026
9adddb3
docs(plan): extension-slot docs implementation plan (Epic 3 story 3-6)
lesnik512 Jun 5, 2026
397ea8c
docs(middleware): write custom-middleware guide (3-6)
lesnik512 Jun 5, 2026
cf242bf
docs(nav): add Middleware page to mkdocs nav (3-6)
lesnik512 Jun 5, 2026
20b9946
docs(readme): link to new Middleware guide (3-6)
lesnik512 Jun 5, 2026
61306fc
docs(index): link to Middleware guide from Where-to-go-next (3-6)
lesnik512 Jun 5, 2026
07ac068
docs(engineering): mark Epic 3 closed (3-6 shipped in v0.7)
lesnik512 Jun 5, 2026
b0aac27
docs: 0.7.0 release notes — middleware guide + Epic 3 closed
lesnik512 Jun 5, 2026
d18a688
docs(spec): v0.7 docs expansion — bundle resilience/errors/testing/ot…
lesnik512 Jun 5, 2026
5604348
docs(plan): v0.7 docs expansion implementation plan
lesnik512 Jun 5, 2026
e140382
docs(middleware): add 'Wiring OpenTelemetry' section
lesnik512 Jun 5, 2026
3192552
docs(resilience): write Retry/RetryBudget/Bulkhead reference
lesnik512 Jun 5, 2026
2fdc871
docs(errors): write exception tree + catching strategies; restore res…
lesnik512 Jun 5, 2026
ea06058
docs(testing): write mock-transport injection pattern guide
lesnik512 Jun 5, 2026
6d13037
docs(nav): add Resilience / Errors / Testing pages to mkdocs nav
lesnik512 Jun 5, 2026
9e1863b
docs(index): expand Where-to-go-next with Resilience / Errors / Testing
lesnik512 Jun 5, 2026
54911c5
docs(engineering): note v0.7 also bundled the rest of user-docs surface
lesnik512 Jun 5, 2026
2e40cdc
docs(release): rewrite 0.7.0 notes for expanded docs scope
lesnik512 Jun 5, 2026
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
133 changes: 133 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
159 changes: 159 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
@@ -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 <method>` 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.
Loading
Loading