|
| 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. |
0 commit comments