Error handling, middleware and CORS

Return consistent error shapes, log unhandled exceptions, time requests with middleware, and configure CORS so browsers can actually call your API.

Errors with a predictable shape

from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()

class AppError(Exception):
    def __init__(self, code: str, message: str, status: int = 400):
        self.code, self.message, self.status = code, message, status

@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
    return JSONResponse(status_code=exc.status,
                        content={"error": {"code": exc.code, "message": exc.message}})

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(status_code=422,
                        content={"error": {"code": "validation_failed",
                                           "fields": exc.errors()}})

Handlers registered for specific exception classes replace the default response entirely. Raising HTTPException stays the right choice for ordinary cases: 401, 403, 404, 409.

Middleware

import time, uuid
from starlette.middleware.base import BaseHTTPMiddleware

class RequestContext(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        request_id = request.headers.get("x-request-id", str(uuid.uuid4()))
        start = time.perf_counter()
        try:
            response = await call_next(request)
        except Exception:
            logger.exception("unhandled error request_id=%s", request_id)
            raise
        response.headers["x-request-id"] = request_id
        response.headers["x-response-time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
        return response

app.add_middleware(RequestContext)
  • Middleware wraps every request, including requests to routes that do not exist.
  • Ordering is the reverse of registration: the last middleware added is the outermost, so add CORS last if it must also cover error responses.
  • An exception raised before await call_next never reaches your route handlers, and an unhandled one bypasses exception_handler registrations made for Exception.
  • Keep middleware cheap: it runs for static files, health checks and every 404 as well.

CORS for browser clients

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)
⚠️
Browsers reject Access-Control-Allow-Origin: * together with credentials, and the error message says nothing about the cause. List origins explicitly when you send cookies; use a wildcard only for a genuinely public, credential-free API.

FAQ

Why does Postman work but the browser fail?
CORS is enforced by the browser only. If the browser console shows a preflight failure, the OPTIONS request is being blocked or redirected. Confirm the middleware is registered and that your reverse proxy forwards OPTIONS with the Origin header.
Should I return 500 details to the client?
No. Log the traceback with the request id, and return a generic message. Leaking stack traces exposes file paths, library versions and sometimes credentials from connection strings.

Your first FastAPI app Authentication with OAuth2, JWT and password hashing

Last refreshed 2026-09-18.