Некоторая логика нужна всем запросам сразу: измерить время ответа, поставить идентификатор для отладки, превратить исключение в понятный 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 может читать и изменять и запрос, и ответ.
Порядок 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 - обработчики уже на месте, нужно родить правильные ошибки из класса товара.
Что почитать дальше
- Dependency Injection и Depends — зависимости, текущий пользователь, сессия БД.
- Валидация с Pydantic — откуда берётся
RequestValidationError. - Наблюдаемость FastAPI — как идентификатор запроса из middleware связывает логи и трейсы.