Phase decorator recipes¶
The @async_before_request, @async_after_response, and @async_on_error decorators from httpware.middleware turn a single async function into an AsyncMiddleware. Reach for them when the logic fits one function — no self state, no need to bracket await next(...) from both sides.
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 AsyncRetry, AsyncBulkhead, and other middleware in a documented order.
This page collects four worked recipes — one minimal and one realistic for @async_before_request, a response-status counter for @async_after_response, and a NetworkError fallback for @async_on_error.
@async_before_request: bearer token¶
The smallest useful case — add a static Authorization header to every outgoing request.
import httpx2
from httpware import AsyncClient
from httpware.middleware import async_before_request
@async_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")
add_bearer is now an AsyncMiddleware instance; pass it directly into middleware=[…]. Order in the list is outer→inner — if you add AsyncRetry() 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).
@async_before_request: correlation ID from contextvars¶
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.
import contextvars
import httpx2
from httpware import AsyncClient, AsyncRetry
from httpware.middleware import async_before_request
_CORRELATION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"correlation_id", default=None,
)
@async_before_request
async def propagate_correlation_id(request: httpx2.Request) -> httpx2.Request:
correlation_id = _CORRELATION_ID.get()
if correlation_id is not None:
request.headers["X-Correlation-Id"] = correlation_id
return request
async def main() -> None:
_CORRELATION_ID.set("abc-123")
async with AsyncClient(
base_url="https://api.example.com",
middleware=[propagate_correlation_id, AsyncRetry()],
) as client:
await client.get("/me") # request carries X-Correlation-Id: abc-123
Two notes worth calling out:
- Placement matters.
propagate_correlation_idsits beforeAsyncRetryin 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 becauseContextVarstate doesn't change between them. - vs
event_hooks. This is also expressible asevent_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.
@async_after_response: counter by status class¶
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.
from collections.abc import Callable
import httpx2
from httpware import AsyncClient
from httpware.middleware import AsyncMiddleware, async_after_response
MetricSink = Callable[[str, int], None]
def status_class_counter(metric_sink: MetricSink) -> AsyncMiddleware:
@async_after_response
async def observe(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response:
status_class = f"{response.status_code // 100}xx"
metric_sink(f"http.{request.method.lower()}.responses.{status_class}", 1)
return response
return observe
def noop_sink(name: str, count: int) -> None:
"""Replace with your statsd / Prometheus / Datadog client."""
async def main() -> None:
async with AsyncClient(
base_url="https://api.example.com",
middleware=[status_class_counter(noop_sink)],
) as client:
await client.get("/me")
Notes:
- 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. - Why not request latency here? Wall-clock timing requires bracketing the
await next(request)call from both sides —@async_after_responseonly sees the response on the way back, so it can't measure the call duration. (response.elapsedfrom httpx2 is also unavailable at this chain point because the body isn't read yet.) Use a rawAsyncMiddlewareclass for timing — see middleware.md for that pattern. - Wiring real sinks: pass
statsd.incr(statsd), aprometheus_client.Counter.incmethod (Prometheus), ordatadog.statsd.increment(Datadog). The signatureCallable[[str, int], None]is loose on purpose. - This middleware does NOT see exceptions. Failed requests (caught by
AsyncRetry, raised asStatusError, or surfaced asNetworkError) never reach@async_after_response. If you want counts of attempted requests including failures, install ahttpware.retrylog handler or write a rawAsyncMiddlewarethat brackets the call.
@async_on_error: fallback on NetworkError¶
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).
import httpx2
from httpware import AsyncClient
from httpware.errors import NetworkError
from httpware.middleware import async_on_error
@async_on_error
async def fallback_on_network_error(
request: httpx2.Request, exc: Exception,
) -> httpx2.Response | None:
if isinstance(exc, NetworkError):
return httpx2.Response(
503,
request=request,
headers={"X-Httpware-Fallback": "network-error"},
content=b'{"degraded": true}',
)
return None
async def main() -> None:
async with AsyncClient(
base_url="https://api.example.com",
middleware=[fallback_on_network_error],
) as client:
response = await client.get("/me")
if response.headers.get("X-Httpware-Fallback") == "network-error":
... # degraded path
Notes:
return Nonere-raises. The decorator only synthesizes a response for cases it actually handles; everything else propagates unchanged. Be specific about which exception types you absorb.- Returning a 4xx/5xx response does NOT re-trigger status mapping. The terminal raises
StatusErroron the upstream response; once your@async_on_errorreturns, the synthesized response flows up the chain unchanged. If you want callers to see aServiceUnavailableError, raise it directly instead of synthesizing. - Catches
Exception, notBaseException.asyncio.CancelledErrorpropagates — your fallback won't accidentally swallow cooperative cancellation. - Placement vs
AsyncRetry. Put@async_on_erroroutsideAsyncRetry(middleware=[fallback_on_network_error, AsyncRetry()]) if you want the fallback to apply only after all retries have failed. InsideAsyncRetry(middleware=[AsyncRetry(), fallback_on_network_error]) the fallback fires on the first network error andAsyncRetrynever sees it. The outer placement is almost always what you want.
See also¶
- Middleware guide — the protocol contract, the raw-
AsyncMiddlewareclass form, and "when NOT to write a middleware". - Resilience reference —
AsyncRetry,RetryBudget,AsyncBulkheadparameters and behaviour. - Errors guide —
NetworkError,StatusError, and the full exception tree.