Часть логики не принадлежит ни одному эндпоинту, но нужна всем: логировать каждый запрос, проставить идентификатор для трассировки, превратить доменное исключение в аккуратный 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, становится основой наблюдаемости — по нему потом сшиваются логи и трейсы, без которых продукт-инженер не разберёт инцидент.