Custom Middleware

Difficulty: Intermediate

Custom middleware lets you add your own processing logic to every request and response. FastAPI provides two ways to create custom middleware: the @app.middleware decorator for simple cases and the BaseHTTPMiddleware class for more complex scenarios.

The @app.middleware('http') decorator is the simplest approach. You write an async function that takes a Request and a call_next callable. You can process the request before calling call_next, get the response, process it, and return it. This is great for simple use cases like timing, logging, and header manipulation.

BaseHTTPMiddleware is a class-based approach that provides more structure. You subclass it and implement the dispatch method. This is better for complex middleware that needs configuration, state, or multiple helper methods. The dispatch method has the same signature as the decorator approach.

Custom middleware can modify both requests and responses. Before calling call_next, you can read and modify request headers, add attributes to the request state, validate the request, or short-circuit by returning a response directly (skipping the handler). After call_next, you can read the response, add or modify headers, log the result, or even replace the entire response.

The request.state attribute is a useful place to store data that middleware wants to pass to handlers. It is an arbitrary namespace that exists for the lifetime of the request. For example, middleware might parse a JWT token and store the user on request.state.user, making it available to all handlers and dependencies.

Pure ASGI middleware is the most performant approach but also the most complex. Instead of using BaseHTTPMiddleware (which has some overhead due to request body buffering), you can write middleware as a pure ASGI callable. This is only necessary for performance-critical applications handling very high request volumes.

Code examples

Simple Decorator Middleware

from fastapi import FastAPI, Request
import time
import uuid

app = FastAPI()

@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = str(uuid.uuid4())
    # Store on request state for handlers to access
    request.state.request_id = request_id

    response = await call_next(request)
    response.headers["X-Request-Id"] = request_id
    return response

@app.middleware("http")
async def add_timing(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    duration = time.perf_counter() - start
    response.headers["X-Process-Time"] = f"{duration:.4f}s"
    return response

@app.get("/")
def root(request: Request):
    return {"request_id": request.state.request_id}

Two middleware functions chain together. add_timing measures the total processing time. add_request_id generates a unique ID and stores it on request.state, making it available to the handler. Both add response headers.

Class-Based Middleware

from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
import time

class RateLimitMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
        super().__init__(app)
        self.max_requests = max_requests
        self.window = window_seconds
        self.requests: dict[str, list[float]] = {}

    async def dispatch(self, request: Request, call_next):
        client_ip = request.client.host if request.client else "unknown"
        now = time.time()

        if client_ip not in self.requests:
            self.requests[client_ip] = []

        # Remove expired timestamps
        self.requests[client_ip] = [
            ts for ts in self.requests[client_ip]
            if now - ts < self.window
        ]

        if len(self.requests[client_ip]) >= self.max_requests:
            return JSONResponse(
                status_code=429,
                content={"detail": "Too many requests"},
                headers={"Retry-After": str(self.window)},
            )

        self.requests[client_ip].append(now)
        response = await call_next(request)
        remaining = self.max_requests - len(self.requests[client_ip])
        response.headers["X-RateLimit-Remaining"] = str(remaining)
        return response

app = FastAPI()
app.add_middleware(RateLimitMiddleware, max_requests=10, window_seconds=60)

@app.get("/")
def root():
    return {"message": "Hello"}

Class-based middleware can accept configuration in __init__ and maintain state. This rate limiter tracks requests per IP and returns 429 when the limit is exceeded. It also adds X-RateLimit-Remaining header to every response.

Short-Circuit Middleware

from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse

class MaintenanceMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, enabled: bool = False):
        super().__init__(app)
        self.enabled = enabled

    async def dispatch(self, request: Request, call_next):
        # Short-circuit: return response without calling handler
        if self.enabled and request.url.path != "/health":
            return JSONResponse(
                status_code=503,
                content={
                    "detail": "Service under maintenance",
                    "retry_after": 300,
                },
            )

        response = await call_next(request)
        return response

app = FastAPI()

# Toggle maintenance mode
app.add_middleware(MaintenanceMiddleware, enabled=False)

@app.get("/")
def root():
    return {"message": "Hello"}

@app.get("/health")
def health():
    return {"status": "ok"}

Middleware can short-circuit by returning a response without calling call_next. This maintenance middleware returns 503 for all endpoints except /health when enabled. The handler is never called during maintenance mode.

Key points

Concepts covered

BaseHTTPMiddleware, dispatch, call_next, Request Modification, Response Modification