Middleware и порядок обработки

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

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

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

Своя middleware

Простейший способ — декоратор @app.middleware("http"). Функция получает request и call_next — корутину, которая передаёт управление дальше по цепочке и возвращает ответ:

import time
from fastapi import FastAPI, Request

app = FastAPI()

@app.middleware("http")
async def add_process_time(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)          # дальше по цепочке к эндпоинту
    elapsed = time.perf_counter() - start
    response.headers["X-Process-Time"] = f"{elapsed:.4f}"
    return response

Всё, что до await call_next(request) — выполняется на входе (можно проверить запрос, начать таймер). Всё, что после — на выходе (можно дополнить ответ). Здесь мы измеряем время обработки и кладём его в заголовок X-Process-Time, который увидит любой клиент. Важно обязательно вернуть response: если этого не сделать, клиент не получит ответ вовсе.

Тот же приём — основа сквозного логирования. На входе генерируют корреляционный идентификатор запроса, кладут его в заголовок ответа и в каждую строку лога; тогда по этому ID можно собрать всю историю одного запроса, даже если он прошёл через несколько сервисов. Логировать в middleware удобно именно потому, что точка одна: метод, путь, итоговый статус и длительность пишутся централизованно, а не повторяются в каждом обработчике. Эндпоинты при этом остаются чистыми — они занимаются только бизнес-логикой, а инфраструктурные заботы (тайминг, заголовки, лог) сосредоточены в слое middleware и применяются ко всему трафику одинаково.

Порядок выполнения

Это место, где ошибаются чаще всего. Middleware образуют «луковицу»: добавленная последней оборачивает добавленные раньше. Запрос проходит слои снаружи внутрь, ответ — изнутри наружу. Разберём идею на чистом Python, не запуская сервер:

def make_mw(name, handler):
    def wrapper(request):
        print(f"-> {name}: вход")
        response = handler(request)
        print(f"<- {name}: выход")
        return response
    return wrapper

def endpoint(request):
    print("   [эндпоинт]")
    return "ok"

# add_middleware(A); add_middleware(B): B обернёт A
app = make_mw("A", endpoint)   # добавлена первой -> ближе к эндпоинту
app = make_mw("B", app)        # добавлена второй -> внешний слой

print(app("req"))

Вывод:

-> B: вход
-> A: вход
   [эндпоинт]
<- A: выход
<- B: выход
ok

Запрос «ныряет» через внешний B, затем A, доходит до эндпоинта, и ответ всплывает обратно в обратном порядке. Практический вывод: middleware, который должен видеть ответ последним (например, общий обработчик ошибок или логгер итогового статуса), добавляйте последним, чтобы он стал самым внешним слоем.

Готовые middleware: CORS и GZip

Самописать всё не нужно — частые задачи закрывают встроенные классы, подключаемые через app.add_middleware.

CORS — доступ из браузера с другого домена

Без CORS браузер заблокирует запрос с app.example.com к вашему API на api.example.com. CORSMiddleware добавляет нужные заголовки:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],   # конкретные домены
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

allow_origins=["*"] разрешает любой источник — приемлемо для публичного открытого API, но несовместимо с allow_credentials=True (нельзя одновременно «любой домен» и «слать куки»). Для продакшена перечисляйте домены явно.

GZip — сжатие ответов

GZipMiddleware сжимает крупные ответы, экономя трафик. minimum_size отсекает мелочь, которую сжимать невыгодно:

from fastapi.middleware.gzip import GZipMiddleware

app.add_middleware(GZipMiddleware, minimum_size=1000)   # сжимать ответы > 1 КБ

Как это работает под капотом

FastAPI построен на ASGI, и middleware — это ASGI-приложения, обёрнутые одно в другое во время старта. Декоратор @app.middleware("http") и метод add_middleware добавляют слой в стек, который собирается в единую вложенную «луковицу» при первом запросе (а на современном Starlette — при событии lifespan-старта). Поэтому стек middleware фиксируется на старте: добавлять middleware после запуска приложения нельзя. Ключевое архитектурное отличие от зависимостей: middleware оборачивает весь процесс, включая маршрутизацию, и не знает, какой именно эндпоинт сработает и какие у него параметры — он работает на «сыром» уровне запрос/ответ. Depends же выполняется уже после выбора маршрута, видит распарсенные параметры и может вернуть значение прямо в обработчик. Грубое правило: сквозное и не привязанное к конкретному пути (CORS, сжатие, лог, тайминг) — middleware; «дай мне сессию БД / текущего пользователя для этого эндпоинта» — зависимость.

Частые ошибки

  • Забыли return response в своей middleware — клиент не получает ответ, запрос «зависает» или падает.
  • Перепутали порядок: думают, что первая добавленная middleware — внешняя. Наоборот, последняя добавленная оборачивает остальные.
  • Поставили allow_origins=["*"] вместе с allow_credentials=True — браузер отвергнет такую комбинацию, куки слаться не будут.
  • Тяжёлую блокирующую работу (запрос к БД, обращение к диску) выполняют синхронно прямо в async-middleware — это блокирует весь event loop и роняет производительность.
  • Пытаются достать в middleware параметры пути или тело, разобранное в схему, — на этом уровне их ещё нет; для этого используют зависимости.

Итоги

  • Middleware оборачивает обработку: код до await call_next() работает на входе, после — на выходе; response нужно вернуть.
  • Порядок — «луковица»: последняя добавленная middleware становится самым внешним слоем.
  • Свою middleware пишут через @app.middleware("http"), готовые подключают через app.add_middleware(Класс, ...).
  • CORSMiddleware разрешает кросс-доменные запросы; allow_origins=["*"] несовместим с allow_credentials=True.
  • Middleware — для сквозных задач на «сыром» уровне; Depends — когда нужны параметры конкретного эндпоинта.
Проверьте себя
1. Вы добавили middleware так: сначала `app.add_middleware(A)`, затем `app.add_middleware(B)`. В каком порядке они увидят входящий запрос?
AСначала B, потом A — последняя добавленная становится внешним слоем
BСначала A, потом B — в порядке добавления
CПорядок недетерминирован
DОбе одновременно, параллельно
2. Что произойдёт, если в своей http-middleware вызвать `await call_next(request)`, но забыть `return response`?
AКлиент не получит ответ от эндпоинта — middleware обязана вернуть response дальше по цепочке
BFastAPI сам вернёт ответ, return не обязателен
CОтвет вернётся, но без заголовков
DЭндпоинт вообще не выполнится