← назад к разделу

PYTHON · FASTAPI Фаза 5. Фундамент - ядро FastAPI

Обязательно

Middleware и обработка ошибок в FastAPI

ASGI · Exception handlers · Problem Details

Сквозная HTTP-логика живёт в middleware, а доменные исключения превращаются в единый формат ответа централизованно.

1. Middleware оборачивает запрос и ответ

1 / 5

Код до call_next выполняется до обработчика, после - на обратном пути. Последняя зарегистрированная middleware оказывается самой внешней.

RequestIdouterTimingcall_nextRoutermatchEndpointyour codeответ возвращается в обратном порядке

2. request_id связывает всё одно событие

2 / 5

Middleware берёт или создаёт X-Request-ID, передаёт его в контекст логов и возвращает клиенту. Тайминг и очистку пишут через finally, потому что при ошибке код после call_next не выполнится.

X-Request-ID7f3a...create if absentContextVar + logsResponse header + timing

3. Exception handler централизует формат

3 / 5

Домен бросает ProductNotFound и не знает про HTTP. Обработчик превращает исключение в 404 с единым телом, такой же подход работает для 400 и 500.

ProductNotFounddomain errorexception_handlermap errorJSONResponse404problem+json

4. HTTPException и валидация уже типизированы

4 / 5

HTTPException задаёт статус и detail прямо в коде. RequestValidationError возникает до обработчика, его handler меняет формат и при необходимости статус.

HTTPExceptionstatus_code=404headers: WWW-AuthenticateRequestValidationErrorPydantic input errorsexc.errors() → violations

5. Middleware и Depends не заменяют друг друга

5 / 5

Middleware видит транспорт каждого запроса: CORS, логи, заголовки. Depends подключается точечно: текущий пользователь, права, сессия базы.

Middlewareвсе запросы · HTTPCORS · request_id · timingDependsвыбранные endpointsuser · roles · session

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

Middleware — логика до и после эндпоинта

Представьте, что каждый запрос проходит сквозь несколько слоёв-«оберток», прежде чем добраться до вашего кода, и возвращается обратно сквозь те же слои. Каждый слой — это middleware.

RequestId зарегистрирован последним Timing зарегистрирован первым роутер маршрут эндпоинт ваш код

Запрос идёт сквозь слои снаружи внутрь, ответ обратно; последний зарегистрированный слой самый внешний, а исключение из эндпоинта пролетает сквозь все слои наружу.

Простейший пример — замер времени обработки запроса:

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 может читать и изменять и запрос, и ответ.

Порядок middleware

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

app.add_middleware(TimingMiddleware)
app.add_middleware(RequestIdMiddleware)  # выполнится первым

Middleware работает на каждом запросе, поэтому тяжёлую логику туда не кладут.

Идентификатор запроса

Частый и полезный паттерн: ставить уникальный request_id на входе, чтобы потом связать все логи одного запроса:

import uuid
from fastapi import Request


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id
    return response

Обработчики исключений — единый формат ошибок

Без централизованной обработки каждый эндпоинт сам превращает ошибку в JSON-ответ. Когда эндпоинтов десятки, формат расходится.

Решение — зарегистрировать обработчик на тип исключения. 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},
    )

Теперь эндпоинт просто бросает ProductNotFound, ничего не зная про HTTP — а обработчик превращает это в 404 с нужным телом.

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-транспорта: логи, тайминги, заголовки, идентификатор запроса.

Зависимость (Depends) подключается только к тем эндпоинтам, которым нужна, и умеет собирать объекты. Текущий пользователь, проверка токена, открытие сессии базы данных — это задачи для зависимости, не для middleware.

Проверку прав на конкретный роут делают зависимостью. Добавить заголовок ко всем ответам — middleware.

Дополнительно: при первом чтении можно пропустить

Глубже: CORS, чистый ASGI и ошибка 500расширенное

Первое middleware, которое понадобится любому API с браузерным клиентом, уже написано за вас. Без него браузер не даст фронтенду с другого адреса даже прочитать ответ:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://shop.example"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Декоратор @app.middleware("http") — это обёртка BaseHTTPMiddleware из Starlette. Она удобна, но читает ответ целиком, ломает потоковые ответы и дорого стоит на каждом запросе. Для горячего пути пишут чистое ASGI-middleware — класс с методом async def __call__(self, scope, receive, send), который работает с событиями напрямую; так устроены и CORSMiddleware, и middleware из библиотек метрик.

Необработанное исключение превращается в 500 не в вашем middleware, а в самом внешнем слое Starlette, поэтому код после call_next при ошибке не выполнится: метрику времени ответа оборачивают в try/finally. Единый формат тела для 500 даёт обработчик на Exception:

@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception):
    return JSONResponse(status_code=500, content={"error": "internal"})

Ошибку с заголовком возвращают через HTTPException(status_code=401, detail="...", headers={"WWW-Authenticate": "Bearer"}) - клиент по заголовку понимает, какую схему входа от него ждут.

Коротко

  • Middleware оборачивает каждый запрос: код до call_next выполняется на входе, после — на выходе.
  • Последняя зарегистрированная middleware выполняется первой — регистрируй логирование последним.
  • @app.exception_handler(SomeError) перехватывает исключение по всему приложению — единый формат ошибок в одном месте.
  • HTTPException — быстрый способ вернуть HTTP-ошибку из кода; RequestValidationError — ошибка Pydantic-валидации, бросается автоматически.
  • Middleware — для сквозного (все запросы, транспортный уровень); зависимость — для конкретных эндпоинтов.
  • CORSMiddleware обязателен для браузерных клиентов; @app.middleware("http") дорог и ломает потоки, горячий путь пишут чистым ASGI; необработанная ошибка не доходит до call_next, единый ответ 500 даёт обработчик на Exception.

Что пощупать

Обработчики ошибок в практикуме remodov/marketplace-system-python переводят исключения домена в статусы: NotFoundError в 404, OutOfStockError и ConflictError в 409, InvalidError в 400, а RequestValidationError - в 400 с картой errors по полям. Тело всегда application/problem+json.

Код: problem.py, errors.py.

Сделаем сами

Ветка step-03-commands-and-errors - обработчики уже на месте, нужно родить правильные ошибки из класса товара.

Что почитать дальше