Middleware
Koa/Hono-style onion composition: code before await next_() runs on the
way in, code after runs on the way out.
import time
@app.use
async def server_timing(c, next_):
start = time.perf_counter()
await next_()
dur = (time.perf_counter() - start) * 1000
c.header("server-timing", f"app;dur={dur:.1f}")
Scope middleware with a URLPattern, or attach it to a single route:
app.use("/admin/*", require_admin)
@app.post("/books", validator("json", BookIn.validate))
async def create(c): ...
Middleware runs even when no route matches, so CORS headers apply to 404/405
responses too. Returning a Response from middleware short-circuits the
chain.
Built-ins (all zero-dependency)
from hayate.middleware import (
basic_auth, body_limit, cache, compress, cors, etag,
logger, rate_limit, request_id, secure_headers, static_files, timeout,
)
| Middleware | What it does |
|---|---|
logger(structured=False) |
text or compact JSON access events via logging |
cors(origin=..., origin_resolver=...) |
Fetch-standard CORS incl. preflight; resolver can inspect c.env |
etag() |
weak ETags + If-None-Match -> 304 (RFC 9110) |
compress() |
gzip everywhere, zstd on Python 3.14+ |
basic_auth(username=..., password=...) |
RFC 7617, timing-safe |
body_limit(max_size=...) |
413 on declared, buffered, or streamed bodies |
timeout(seconds=...) |
504 via asyncio.timeout |
secure_headers() |
CSP/HSTS/nosniff/frame/referrer, composed at startup |
cache(max_age=...) |
identity-aware, byte-bounded GET micro-cache + Cache-Control/Age (RFC 9111) |
static_files(root=...) |
files with ETag/304, single Range 206/416, traversal-safe |
rate_limit(limit=..., window=..., key=...) |
quota + 429/Retry-After, advertised via RateLimit/RateLimit-Policy (draft-ietf-httpapi-ratelimit-headers) |
request_id() |
safe X-Request-ID generation/preservation, c.get("request_id"), and response correlation |
Static assets, Hono-style:
app.use("/assets/*", static_files(root="public", strip_prefix="/assets"))
Rate limiting — key names the quota partition, and choosing it is a
trust-boundary decision (peer IP behind your proxy, an API key, a user id),
so it has no default. Return None to skip a request. The bundled store is
in-memory and per-process; inject a RateLimitStore over shared storage when
you scale out:
app.use("/api/auth/*", rate_limit(
limit=10, window=60,
key=lambda c: c.req.header("cf-connecting-ip"),
))
The response micro-cache is public and URL-keyed by default. Credential-bearing
requests bypass it unless the application supplies a trusted partition key.
Private caches require that key, and returning None bypasses caching for the
request. Keyed responses are always marked private, because downstream shared
caches cannot see the application-only partition. Cached bodies plus headers
are bounded by max_bytes (16 MiB by default); responses carrying Set-Cookie
are never stored:
app.use("/account/*", cache(
max_age=30,
private=True,
max_bytes=4 * 1024 * 1024,
key=lambda c: c.get("principal")["subject"] if c.get("principal") else None,
))
Workers applications can resolve a CORS allowlist from a request-bound environment without replacing the middleware:
def from_env(c, request_origin):
allowed = c.env["CORS_ORIGINS"]
return request_origin if request_origin in allowed else None
app.use(cors(origin_resolver=from_env, credentials=True))
Request correlation uses a bounded log-safe value instead of trusting an
arbitrary caller-controlled header. Register it before middleware that reads
the context value. Structured access logs have stable event, method,
path, status, duration_ms, and request_id fields; query strings,
headers, and bodies are not logged:
from hayate.middleware import logger, request_id
app.use(request_id())
app.use(logger(structured=True))
@app.get("/")
async def home(c):
return c.text("ok")
{"event":"http_request","method":"GET","path":"/","status":200,"duration_ms":0.123,"request_id":"..."}
Application logs can use the same ID without passing Context through every
layer. Attach RequestIdFilter to the handlers that emit those records:
import logging
from hayate.middleware import RequestIdFilter, current_request_id
handler = logging.StreamHandler()
handler.addFilter(RequestIdFilter())
application_log = logging.getLogger("application")
application_log.addHandler(handler)
@app.get("/work")
async def work(c):
application_log.info("work") # the LogRecord has record.request_id
assert current_request_id() == c.get("request_id")
return c.text("ok")
The async logging context remains available to app.on_error and is restored
when the final response is known. Concurrent requests stay isolated, while
work created with c.wait_until() captures the request ID for deferred logs.
Access events are emitted only after the final response is known. Statuses
therefore reflect HTTPException and custom app.on_error responses even
when the exception originated in another middleware. A logging-sink failure
is isolated from the application response.
Set accept_incoming=False and provide a generator when a platform-issued
value such as a Cloudflare or Lambda request ID must take precedence.
Writing your own
A middleware is async def mw(c, next_); a factory returning one is the
idiomatic packaging.