Skip to content

Commit d254ca2

Browse files
lesnik512claude
andcommitted
docs(recipes): add phase-decorator-patterns recipe
Four worked examples for the @before_request / @after_response / @on_error decorators, plus a sharpened framing in middleware.md that points users at httpx2.event_hooks for cases that don't need httpware's exception mapping or chain ordering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc766ea commit d254ca2

3 files changed

Lines changed: 172 additions & 19 deletions

File tree

docs/middleware.md

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -47,28 +47,12 @@ from httpware.middleware import before_request, after_response, on_error
4747
| `@after_response` | `async (request, response) -> response` | Transform the incoming response (decode, log, attach metadata). |
4848
| `@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. |
4949

50-
Brief example — adding an `Authorization` header before every request:
51-
52-
```python
53-
import httpx2
54-
55-
from httpware import AsyncClient
56-
from httpware.middleware import before_request
57-
58-
59-
@before_request
60-
async def add_bearer(request: httpx2.Request) -> httpx2.Request:
61-
request.headers["Authorization"] = "Bearer secret-token"
62-
return request
63-
64-
65-
async def main() -> None:
66-
async with AsyncClient(base_url="https://api.example.com", middleware=[add_bearer]) as client:
67-
await client.get("/me")
68-
```
50+
See the **[Phase decorator recipes](recipes/phase-decorator-patterns.md)** for worked examples covering each decorator: bearer-token injection, correlation-ID propagation from `contextvars`, status-class counter, and `NetworkError` fallback.
6951

7052
**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.
7153

54+
**Reach for `httpx2.event_hooks` instead when:** the transform doesn't need `httpware`'s exception mapping or chain ordering — pure request/response side effects at the lowest level. Phase decorators participate in the `httpware` middleware chain (they see `httpware` exceptions and compose with `Retry`/`Bulkhead`); `event_hooks` run a layer below, on every transport attempt including post-redirect hops. For static header injection or response logging that doesn't care about either property, a hook installed on the wrapped `httpx2_client` is the simpler tool.
55+
7256
## Worked example: request-ID propagation
7357

7458
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.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Phase decorator recipes
2+
3+
The `@before_request`, `@after_response`, and `@on_error` decorators from `httpware.middleware` turn a single async function into a `Middleware`. Reach for them when the logic fits one function — no `self` state, no need to bracket `await next(...)` from both sides.
4+
5+
When the same logic would fit `httpx2.event_hooks` instead, prefer the hook: it's a layer below httpware's exception mapping and chain ordering, and is the right place for transforms that don't need either. Phase decorators participate in the middleware chain — they see `httpware` exceptions (mapped from `httpx2` ones), and they compose with `Retry`, `Bulkhead`, and other middleware in a documented order.
6+
7+
This page collects four worked recipes — one minimal and one realistic for `@before_request`, a response-status counter for `@after_response`, and a `NetworkError` fallback for `@on_error`.
8+
9+
## `@before_request`: bearer token
10+
11+
The smallest useful case — add a static `Authorization` header to every outgoing request.
12+
13+
```python
14+
import httpx2
15+
16+
from httpware import AsyncClient
17+
from httpware.middleware import before_request
18+
19+
20+
@before_request
21+
async def add_bearer(request: httpx2.Request) -> httpx2.Request:
22+
request.headers["Authorization"] = "Bearer secret-token"
23+
return request
24+
25+
26+
async def main() -> None:
27+
async with AsyncClient(
28+
base_url="https://api.example.com",
29+
middleware=[add_bearer],
30+
) as client:
31+
await client.get("/me")
32+
```
33+
34+
`add_bearer` is now a `Middleware` instance; pass it directly into `middleware=[…]`. Order in the list is outer→inner — if you add `Retry()` after, the bearer header is set on every retry attempt (which is what you want — each attempt is a real HTTP call and needs auth).
35+
36+
## `@before_request`: correlation ID from `contextvars`
37+
38+
A more realistic case — propagate a correlation ID set by your application's surrounding context (FastAPI middleware, structlog binder, etc.). The decorator pulls the ID out of a `ContextVar` and stamps it on the outgoing request.
39+
40+
```python
41+
import contextvars
42+
43+
import httpx2
44+
45+
from httpware import AsyncClient, Retry
46+
from httpware.middleware import before_request
47+
48+
49+
_CORRELATION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
50+
"correlation_id", default=None,
51+
)
52+
53+
54+
@before_request
55+
async def propagate_correlation_id(request: httpx2.Request) -> httpx2.Request:
56+
correlation_id = _CORRELATION_ID.get()
57+
if correlation_id is not None:
58+
request.headers["X-Correlation-Id"] = correlation_id
59+
return request
60+
61+
62+
async def main() -> None:
63+
_CORRELATION_ID.set("abc-123")
64+
async with AsyncClient(
65+
base_url="https://api.example.com",
66+
middleware=[propagate_correlation_id, Retry()],
67+
) as client:
68+
await client.get("/me") # request carries X-Correlation-Id: abc-123
69+
```
70+
71+
Two notes worth calling out:
72+
73+
- **Placement matters.** `propagate_correlation_id` sits *before* `Retry` in the chain, so it re-runs for each retry attempt. The header is set on every attempt, but the ID itself stays the same across attempts because `ContextVar` state doesn't change between them.
74+
- **vs `event_hooks`.** This is also expressible as `event_hooks={"request": [propagate_correlation_id]}` on the wrapped httpx2 client, with one functional difference: hooks run *below* the httpware chain, so they fire on every transport attempt including post-redirect hops. For correlation IDs the behaviour is usually equivalent; for anything that should fire once per *logical* call (e.g. a UUID generated inline), the phase decorator is correct.
75+
76+
## `@after_response`: counter by status class
77+
78+
Side-effect-only recipe — increment a counter keyed by status class (`2xx`, `4xx`, `5xx`) every time a response comes back. The decorator returns the response unchanged.
79+
80+
```python
81+
from collections.abc import Callable
82+
83+
import httpx2
84+
85+
from httpware import AsyncClient
86+
from httpware.middleware import Middleware, after_response
87+
88+
89+
MetricSink = Callable[[str, int], None]
90+
91+
92+
def status_class_counter(metric_sink: MetricSink) -> Middleware:
93+
@after_response
94+
async def observe(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response:
95+
status_class = f"{response.status_code // 100}xx"
96+
metric_sink(f"http.{request.method.lower()}.responses.{status_class}", 1)
97+
return response
98+
99+
return observe
100+
101+
102+
def noop_sink(name: str, count: int) -> None:
103+
"""Replace with your statsd / Prometheus / Datadog client."""
104+
105+
106+
async def main() -> None:
107+
async with AsyncClient(
108+
base_url="https://api.example.com",
109+
middleware=[status_class_counter(noop_sink)],
110+
) as client:
111+
await client.get("/me")
112+
```
113+
114+
Notes:
115+
116+
- The factory function `status_class_counter(metric_sink)` is the canonical way to parameterize a phase decorator — the decorated function itself takes no extra args, but the enclosing factory can.
117+
- **Why not request *latency* here?** Wall-clock timing requires bracketing the `await next(request)` call from both sides — `@after_response` only sees the response on the way back, so it can't measure the call duration. (`response.elapsed` from httpx2 is also unavailable at this chain point because the body isn't read yet.) Use a raw `Middleware` class for timing — see [middleware.md](../middleware.md) for that pattern.
118+
- Wiring real sinks: pass `statsd.incr` (statsd), a `prometheus_client.Counter` `.inc` method (Prometheus), or `datadog.statsd.increment` (Datadog). The signature `Callable[[str, int], None]` is loose on purpose.
119+
- This middleware does NOT see exceptions. Failed requests (caught by `Retry`, raised as `StatusError`, or surfaced as `NetworkError`) never reach `@after_response`. If you want counts of *attempted* requests including failures, install a `httpware.retry` log handler or write a raw `Middleware` that brackets the call.
120+
121+
## `@on_error`: fallback on `NetworkError`
122+
123+
When the upstream is unreachable, return a synthesized 503 with a sentinel header so callers can branch on degraded mode. The decorator returns a `Response` on `NetworkError`, and `None` for everything else (re-raise).
124+
125+
```python
126+
import httpx2
127+
128+
from httpware import AsyncClient
129+
from httpware.errors import NetworkError
130+
from httpware.middleware import on_error
131+
132+
133+
@on_error
134+
async def fallback_on_network_error(
135+
request: httpx2.Request, exc: Exception,
136+
) -> httpx2.Response | None:
137+
if isinstance(exc, NetworkError):
138+
return httpx2.Response(
139+
503,
140+
request=request,
141+
headers={"X-Httpware-Fallback": "network-error"},
142+
content=b'{"degraded": true}',
143+
)
144+
return None
145+
146+
147+
async def main() -> None:
148+
async with AsyncClient(
149+
base_url="https://api.example.com",
150+
middleware=[fallback_on_network_error],
151+
) as client:
152+
response = await client.get("/me")
153+
if response.headers.get("X-Httpware-Fallback") == "network-error":
154+
... # degraded path
155+
```
156+
157+
Notes:
158+
159+
- **`return None` re-raises.** The decorator only synthesizes a response for cases it actually handles; everything else propagates unchanged. Be specific about which exception types you absorb.
160+
- **Returning a 4xx/5xx response does NOT re-trigger status mapping.** The terminal raises `StatusError` on the upstream response; once your `@on_error` returns, the synthesized response flows up the chain unchanged. If you want callers to see a `ServiceUnavailableError`, raise it directly instead of synthesizing.
161+
- **Catches `Exception`, not `BaseException`.** `asyncio.CancelledError` propagates — your fallback won't accidentally swallow cooperative cancellation.
162+
- **Placement vs `Retry`.** Put `@on_error` *outside* `Retry` (`middleware=[fallback_on_network_error, Retry()]`) if you want the fallback to apply only after all retries have failed. Inside `Retry` (`middleware=[Retry(), fallback_on_network_error]`) the fallback fires on the first network error and `Retry` never sees it. The outer placement is almost always what you want.
163+
164+
## See also
165+
166+
- **[Middleware guide](../middleware.md)** — the protocol contract, the raw-`Middleware` class form, and "when NOT to write a middleware".
167+
- **[Resilience reference](../resilience.md)**`Retry`, `RetryBudget`, `Bulkhead` parameters and behaviour.
168+
- **[Errors guide](../errors.md)**`NetworkError`, `StatusError`, and the full exception tree.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ nav:
1212
- Testing: testing.md
1313
- Recipes:
1414
- modern-di: recipes/modern-di.md
15+
- Phase decorator patterns: recipes/phase-decorator-patterns.md
1516
- Development:
1617
- Contributing: dev/contributing.md
1718

0 commit comments

Comments
 (0)