Handbooks / FastAPI / Chapter 5
Middleware and Exception Handlers
7 pages · ~5 min✓ Reviewed
Builds on APIRouter and Project Structure. Next up: Authentication and Security.
Middleware Mental Model
1. Wraps the Whole App
- Sees every request before routing and every response after the endpoint
- Fits cross-cutting work: logging, timing, headers, auth checks
- Code before
call_nextruns on the way in; code after runs on the way out - The first middleware added is the innermost layer
- Request
- Last addedoutermost
- First addedinnermost
- Endpoint
2. Simplest Form
@app.middleware('http')uses BaseHTTPMiddleware underneath- Await
call_next, then return its response
@app.middleware('http') async def mw(request, call_next): # in: before the endpoint response = await call_next(request) # out: after the endpoint return response
| Middleware | Dependency | Route handler | |
|---|---|---|---|
| Scope | global | per-route | one route |
| Sees | raw request | parsed params | parsed params |
3. Mistakes
- Forgot to return: must return what
call_nextgave, or the request fails - Reading await request.body() in BaseHTTPMiddleware can consume the stream and break the endpoint
Pure ASGI Middleware
1. The ASGI Contract
- App signature: async def call(self, scope, receive, send)
- Middleware = class holding
self.app, delegating withawait self.app(scope, receive, send) scope['type']is http, websocket or lifespan- Always pass non-http scopes straight through
class AddHeader: def __init__(self, app, value): self.app, self.value = app, value async def __call__(self, scope, receive, send): if scope['type'] != 'http': return await self.app(scope, receive, send) async def send_wrapper(message): if message['type'] == 'http.response.start': message['headers'].append((b'x-app', self.value)) await send(message) await self.app(scope, receive, send_wrapper)
wrap send to edit the response
2. Wrapping send and Registering
- Intercept http.response.start to edit headers, then forward
message['headers']is a list of byte-tuple pairs- Register:
add_middleware(Cls, option=value); kwargs go to__init__(self, app, option)
app.add_middleware(AddHeader, value=b'v1')Middleware Ordering
1. Stack Order
- Last
add_middlewarecall = outermost layer: first to see the request, last to see the response - First one added sits closest to the app
- Request order is the reverse of registration; response order is registration order
- Register A then B: request path B → A → app
2. Where Each Layer Goes
| Layer | Registered | Order a request meets it (outermost first) |
|---|---|---|
| TrustedHost | 5th (last) | 1 |
| CORS | 4th | 2 |
| GZip | 3rd | 3 |
| Request-ID / logging | 2nd | 4 |
| Auth | 1st (first) | 5, then the app |
Built-in Middleware: GZip and TrustedHost
1. GZipMiddleware
app.add_middleware(
GZipMiddleware,
minimum_size=1000,
compresslevel=9)minimum_size defaults to 500 bytes
- Accept-Encoding: gzip?client asks
- Body >= minimum_size?bytes
- CompressContent-Encoding: gzip
- Adds Content-Encoding: gzip and Vary: Accept-Encoding
- Skips responses that are already encoded
- Streaming responses are compressed chunk by chunk
2. TrustedHostMiddleware
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=['example.com',
'*.example.com'])checks the Host header
| Setting | Effect |
|---|---|
| Host not allowed | 400 Invalid host header |
'*.example.com' | Subdomains only, not the bare domain: list both |
Exceptions and Custom Handlers
1. Raise and Register
raise HTTPExceptionstops the endpoint and returns JSON{"detail": ...}- detail can be any JSON-serializable value, not only a string
headers=adds custom response headers- Handler is chosen by the exception class and its parents
class MyError(Exception): ... @app.exception_handler(MyError) async def h(request, exc): return JSONResponse(status_code=418, content={"msg": "x"}) raise HTTPException(404, detail="Item not found", headers={"X-Error": "x"})
custom handler, then a raise
2. Override the Defaults
| Handle | Catches |
|---|---|
StarletteHTTPException | your raises, unknown-route 404s, Starlette errors |
FastAPI HTTPException | only errors you raise |
RequestValidationError | bad body, query or path → 422 |
Consistent Errors and Cheat Sheet
1. One Envelope, Three Handlers
def error(status, code, message, details=()): body = {"error": {"code": code, "message": message, "details": list(details), "request_id": request_id.get()}} return JSONResponse(body, status_code=status)
one helper, used by every handler
| Handler | Covers | Returns |
|---|---|---|
StarletteHTTPException | HTTP errors, unknown routes | exc.status_code |
RequestValidationError | bad body, query, path | 422, exc.errors() |
Exception | everything else | 500, generic |
- Catch-all: log the traceback, return a generic message
- Never leak internals to the client
2. Request-ID Middleware (pure ASGI)
- Set IDcontextvar
- EchoX-Request-ID
- Logevery line
- Error bodyrequest_id
Check yourself
You call add_middleware(A) and then add_middleware(B). Each prints its name before and after call_next. What prints for one request?
B in, A in, endpoint, A out, B out- The last one added is outermost, so it sees the request first.
A middleware does raise HTTPException(401) for a missing token. The client gets a 500. Why, and what is the fix?
- Exceptions raised in middleware bypass the exception handlers.
- Return
JSONResponse(status_code=401, ...)directly.
With allowed_hosts=['*.example.com'], a request arrives with Host: example.com. What is the result?
- 400
Invalid host header: the wildcard does not match the bare domain. - List both
example.comand*.example.com.