Writing Middleware¶
Middleware sits between the server and your route handlers, processing every request and response that flows through your application. It’s the right tool for cross-cutting concerns — things that apply to all requests, not just specific routes.
Common middleware use cases:
Request logging and timing
Authentication and authorization
Adding security headers
Request ID generation
Rate limiting
Response compression (built-in)
Hooks vs. Middleware¶
Responder gives you two levels of request processing:
Hooks (before_request / after_request) run inside Responder’s
routing layer. They receive Responder’s req and resp objects and
are the simplest way to add behavior:
@api.route(before_request=True)
def add_header(req, resp):
resp.headers["X-Powered-By"] = "Responder"
@api.after_request()
def log_request(req, resp):
print(f"{req.method} {req.url.path} -> {resp.status_code}")
Note
req.method is uppercase ("GET", "POST", …) and compares
case-sensitively, so compare against uppercase strings: req.method == "GET".
Middleware runs at the ASGI level, wrapping the entire application. It’s more powerful but more complex — you work with raw ASGI scopes instead of Responder objects. Use middleware when you need to process requests before they reach Responder’s routing, or when you need to integrate with Starlette middleware.
Function Middleware¶
The quickest way to write HTTP middleware is the @api.middleware("http")
decorator — a plain function that receives the Starlette Request and a
call_next callable, no class boilerplate required:
import time
@api.middleware("http")
async def add_timing(request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed = time.perf_counter() - start
response.headers["X-Response-Time"] = f"{elapsed:.4f}s"
return response
Call call_next(request) to pass the request onward; the return value is a
Starlette Response you can modify (or replace — return your own response
without calling call_next to short-circuit).
Synchronous functions work too. They are offloaded to the threadpool like
sync views, and the call_next they receive is a plain blocking callable:
@api.middleware("http")
def tag_response(request, call_next):
response = call_next(request)
response.headers["X-Tag"] = "handled"
return response
Function middleware registers through the same stack as add_middleware,
so the two compose freely — see Middleware Order for where it sits.
Non-HTTP traffic (WebSockets, lifespan) passes through untouched.
Using Starlette Middleware¶
Responder is built on Starlette, so any Starlette middleware works out of the box:
from starlette.middleware.base import BaseHTTPMiddleware
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
import time
start = time.time()
response = await call_next(request)
duration = time.time() - start
response.headers["X-Response-Time"] = f"{duration:.3f}s"
return response
api.add_middleware(TimingMiddleware)
The dispatch method receives a Starlette Request and a
call_next function. Call call_next(request) to pass the request
to the next middleware (or to your route handler). The return value is
a Starlette Response that you can modify before it’s sent.
Built-in Middleware¶
Every Responder app ships with a small stack wired up for you:
ServerErrorMiddleware — catches unhandled exceptions and renders a 500
ExceptionMiddleware — routes
HTTPExceptions and status codes to your handlersTrustedHostMiddleware — validates the
Hostheader (["*"]by default)GZipMiddleware — compresses responses larger than 500 bytes (on by default)
A few more are wired in on demand, by constructor flag:
SessionMiddleware — signed cookie sessions, on unless you pass
sessions=False. Secure by default: the signing key never falls back to a public default, cookies areSecurein production, andreq.session/resp.sessionraiseRuntimeErrorwhen sessions are off. See Configuration for the full story.CORSMiddleware —
responder.API(cors=True)HTTPSRedirectMiddleware —
responder.API(enable_hsts=True)RequestIDMiddleware —
responder.API(request_id=True)adds anX-Request-IDheader to every responseLoggingMiddleware —
responder.API(enable_logging=True)for structured per-request logging (it handles request IDs itself, supersedingrequest_id)MetricsMiddleware —
responder.API(metrics_route="/metrics")exposes Prometheus metrics
The observability options (request IDs, logging, metrics) are covered in Feature Tour.
Adding Third-Party Middleware¶
Any ASGI middleware can be added with api.add_middleware():
from some_package import SomeMiddleware
api.add_middleware(SomeMiddleware, option1="value", option2=True)
Keyword arguments are passed to the middleware’s constructor.
Middleware can be registered any time before the first request, not only at
construction — the ASGI stack is assembled lazily and rebuilt whenever you add
more. That assembled stack is exposed as the read-only api.app property, so
you can’t inject middleware by assigning to it. Use api.add_middleware(), or
wrap the API object itself for a truly outermost layer (see Middleware Order).
Middleware Order¶
Middleware wraps your application like the layers of an onion. A request travels inward through every layer to your route, and the response travels back outward in reverse.
The full built-in stack, from outermost to innermost, is:
LoggingMiddleware (
enable_logging=True) or RequestIDMiddleware (request_id=True) — the observability tier. It wraps everything below, so even a rendered 500 carries itsX-Request-IDand real status.MetricsMiddleware (
metrics_route=...)ServerErrorMiddleware — the outermost application layer; it catches errors from every middleware and route beneath it.
your middleware (added with
add_middlewareor@api.middleware)TrustedHostMiddleware
HTTPSRedirectMiddleware (
enable_hsts=True)CORSMiddleware (
cors=True)SessionMiddleware (unless
sessions=False)GZipMiddleware (on by default)
ExceptionMiddleware — routes non-500 exceptions to your handlers
your routes
Two consequences worth knowing:
Your middleware sits inside
ServerErrorMiddleware, so an exception it raises is caught and rendered as a 500 instead of crashing the server.Sessions sit beneath
ServerErrorMiddleware, so they are not persisted on an unhandled 500.
api.add_middleware() inserts your middleware just inside
ServerErrorMiddleware — not at the very top of the stack. Among your own
middleware — function-style and class-based alike — the most-recently-added is
the outermost and runs first, so if middleware A depends on B having run
first, add B before A.
To wrap everything — including error rendering and the observability tier — wrap the API object itself:
asgi = MyOutermostMiddleware(api)
That asgi callable is what you then serve.
Writing Pure ASGI Middleware¶
For maximum performance and control, you can write middleware as a plain
ASGI application. This bypasses Starlette’s BaseHTTPMiddleware
abstraction — it’s faster and gives you direct access to the ASGI
protocol:
class SecurityHeadersMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def send_with_headers(message):
if message["type"] == "http.response.start":
extra = [
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
(b"referrer-policy", b"strict-origin-when-cross-origin"),
]
message["headers"] = list(message["headers"]) + extra
await send(message)
await self.app(scope, receive, send_with_headers)
api.add_middleware(SecurityHeadersMiddleware)
This is the same pattern used internally by Starlette and uvicorn. The
middleware receives the ASGI scope, receive, and send callables,
and wraps send to inject headers into the response.
For most cases, BaseHTTPMiddleware is simpler and perfectly fine.
Use the pure ASGI approach when you need to handle WebSocket connections,
streaming responses, or want to avoid the overhead of request/response
object creation.
When to Use What¶
Simple header additions, logging, auth checks → use hooks
Response transformation, timing, third-party integrations → use middleware (start with
@api.middleware("http"))Rate limiting → use the built-in
RateLimiter(it uses hooks internally)Request ID → use
api = responder.API(request_id=True)
Start with hooks. They’re simpler and cover most cases. Graduate to middleware when hooks aren’t enough.