Часть логики не принадлежит ни одному эндпоинту, но нужна всем: логировать каждый запрос, проставить идентификатор для трассировки, превратить доменное исключение в аккуратный JSON. Раскладывать это по обработчикам — значит дублировать и забывать. FastAPI даёт два механизма для сквозной логики: middleware и обработчики исключений.

Middleware

Middleware оборачивает обработку запроса: получает запрос до эндпоинта и ответ после, может добавить к обоим что-то общее. Простейшая форма — декоратор.

import time

from fastapi import Request


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

call_next передаёт управление дальше по цепочке (к следующему middleware или к эндпоинту) и возвращает ответ. Всё до call_next выполняется на входе, всё после — на выходе.

Middleware образуют стек, и порядок важен: то, что должно видеть вообще всё (логирование, идентификатор запроса), ставят так, чтобы оно оборачивало остальные, — иначе оно не покроет то, что добавлено «снаружи» него. Тяжёлую логику в middleware не кладут: оно на пути каждого запроса.

Обработчики исключений

Второй механизм — единый перехват ошибок. Вместо того чтобы в каждом эндпоинте превращать исключение в ответ, регистрируют обработчик на тип исключения, и FastAPI применит его везде.

from fastapi import Request
from fastapi.responses import JSONResponse


class ProductNotFound(Exception):
    def __init__(self, product_id: int):
        self.product_id = product_id


@app.exception_handler(ProductNotFound)
async def product_not_found_handler(request: Request, exc: ProductNotFound):
    return JSONResponse(
        status_code=404,
        content={"error": "product_not_found", "product_id": exc.product_id},
    )

Теперь Handler может просто бросить ProductNotFound, не зная про HTTP, — а обработчик переведёт это в 404 с единым телом. Это держит контроллеры и Handler-ы чистыми: доменные ошибки выражаются доменными исключениями, а перевод в протокол — в одном месте.

Ошибки валидации и HTTPException

Две ошибки FastAPI обрабатывает сам, но их поведение полезно знать и при необходимости переопределить. HTTPException — быстрый способ вернуть ошибку прямо из кода:

from fastapi import HTTPException


@router.get("/{product_id}")
async def get_product(product_id: int):
    product = await repo.find(product_id)
    if product is None:
        raise HTTPException(status_code=404, detail="not found")
    return product

RequestValidationError FastAPI бросает сам, когда тело или параметры не прошли Pydantic-проверку, и отдаёт 422. Если нужен единый формат ошибок по всему API, его перехватывают так же, как доменное исключение:

from fastapi.exceptions import RequestValidationError


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

Граница: middleware или зависимость

Middleware и Depends иногда путают. Разница в области: middleware видит каждый запрос и работает на уровне HTTP (логи, тайминги, заголовки); зависимость подключается к конкретным эндпоинтам и собирает объекты или проверяет доступ (текущий пользователь — это зависимость, не middleware). Проверку прав на отдельный роут делают зависимостью, а не middleware на всё приложение.

Вместе middleware и обработчики исключений дают то же, что аспекты и @ControllerAdvice в Spring-биндинге: сквозная логика и единый формат ошибок в одном месте, а не размазанные по коду. А идентификатор запроса, проставленный в middleware, становится основой наблюдаемости — по нему потом сшиваются логи и трейсы, без которых продукт-инженер не разберёт инцидент.