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, secure_headers, static_files, timeout,
)
| Middleware | What it does |
|---|---|
logger() |
METHOD /path -> status (ms) 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=...) |
in-process 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) |
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"),
))
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))
Writing your own
A middleware is async def mw(c, next_); a factory returning one is the
idiomatic packaging:
def request_id() -> Middleware:
async def request_id_middleware(c, next_):
rid = c.req.header("x-request-id") or new_id()
c.set("request_id", rid)
await next_()
c.header("x-request-id", rid)
return request_id_middleware