FastAPI Custom Middleware and Request Lifecycle
Every request that enters your FastAPI app travels through a defined sequence of steps before your route function runs, and another sequence after it finishes. Understanding this lifecycle lets you intercept and modify requests and responses at exactly the right moment.
The Complete Request Lifecycle
Client sends request
│
▼
1. Uvicorn (web server) receives raw HTTP
│
▼
2. Starlette ASGI layer parses the request
│
▼
3. Middleware stack (outermost first)
├── Middleware A (before)
├── Middleware B (before)
└── Middleware C (before)
│
▼
4. Router matches URL to route function
│
▼
5. Dependencies resolved (Depends chain)
│
▼
6. Request body parsed and validated (Pydantic)
│
▼
7. Route function executes
│
▼
8. Response model applied (filter output)
│
▼
9. Middleware stack (innermost first)
├── Middleware C (after)
├── Middleware B (after)
└── Middleware A (after)
│
▼
10. Uvicorn sends response to client
Writing a Middleware Class (Starlette Style)
For more complex middleware, inherit from BaseHTTPMiddleware:
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
import uuid
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
request.state.request_id = request_id ← attach to request
response = await call_next(request)
response.headers["X-Request-ID"] = request_id ← add to response
return response
app.add_middleware(RequestIDMiddleware)
Every response now has: X-Request-ID: 3f7a9b2e-1234-5678-abcd-ef0123456789 Every route can read it via: request.state.request_id
Using request.state to Share Data
request.state is a simple namespace that lives for the duration of one request. Middleware can write to it; route functions and dependencies can read from it:
# Middleware writes:
request.state.start_time = time.time()
request.state.user_country = detect_country(request)
# Route reads:
@app.get("/items")
def get_items(request: Request):
country = request.state.user_country
return {"country": country}
Rate Limiting Middleware
from collections import defaultdict
import time
request_counts = defaultdict(list)
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
ip = request.client.host
now = time.time()
# Keep only timestamps from the last 60 seconds
request_counts[ip] = [t for t in request_counts[ip] if now - t < 60]
if len(request_counts[ip]) >= 100:
from starlette.responses import JSONResponse
return JSONResponse(
{"error": "Rate limit exceeded: 100 requests per minute"},
status_code=429
)
request_counts[ip].append(now)
return await call_next(request)
IP 1.2.3.4 sends 100 requests in 60 seconds: Requests 1–100 → allowed, proceed to routes Request 101 → blocked: 429 Too Many Requests
Middleware Execution Order — Addition vs Execution
app.add_middleware(MiddlewareA) ← added first app.add_middleware(MiddlewareB) ← added second app.add_middleware(MiddlewareC) ← added third (outermost) Request travels: C → B → A → route Response travels: route → A → B → C The last middleware added wraps all the others.
Accessing the Raw Request Object in Routes
Inject Request directly into a route function to read headers, client info, or state set by middleware:
from fastapi import Request
@app.get("/info")
def request_info(request: Request):
return {
"client_ip": request.client.host,
"method": request.method,
"url": str(request.url),
"headers": dict(request.headers),
"request_id": getattr(request.state, "request_id", None)
}
Key Points
- A FastAPI request passes through: server → middleware → router → dependencies → validation → route → middleware → server.
- Use
BaseHTTPMiddlewarefor class-based middleware with clean separation. - Use
request.stateto pass data from middleware to routes within a single request. - The last middleware added runs outermost — it sees the request first and the response last.
- Inject
Requestdirectly into a route function to access raw request data.
