Middleware: код, который выполняется при каждом запросе

Цели урока
  • Объяснить, что такое Middleware и какую проблему оно решает
  • Писать Middleware с @app.middleware(“http”) и call_next
  • Понимать, что делают async и await внутри Middleware
  • Добавить в API студентов лог и измерение времени для каждого запроса, не меняя ни одного маршрута

Что мы изучим?

  • Какая возникает проблема, когда нужно что-то делать при каждом запросе.
  • Что такое Middleware и как оно связано с уже знакомым нам декоратором.
  • @app.middleware("http"), call_next, async и await.
  • Лог и измерение времени для каждого запроса в API студентов, а также заголовок, который добавляется к каждому ответу.

Зачем это нужно?

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

@app.get("/students")
def list_students():
    logging.info("GET /students")
    return students

@app.get("/students/{student_id}")
def get_student(student_id: int):
    logging.info("GET /students/" + str(student_id))
    ...

Три проблемы:

  1. Одна и та же строка копируется в каждый маршрут — пять, десять, пятьдесят раз.
  2. Новый маршрут, в котором строку забыли, просто не логируется.
  3. Так нельзя узнать код ответа — 404 и 400 создаются в raise HTTPException, после строки лога, а 422 создаётся ещё до выполнения маршрута. И нельзя узнать, сколько длился запрос.

Объяснение с основ

Идея — Middleware

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

Client --request--> Middleware --------> Route (your function)
                    before: start the timer
Client <-response-- Middleware <-------- Route
                    after: status code, duration, log

Как декоратор — для всего сервера

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

ДекораторMiddleware
ОборачиваетОдну функциюКаждый запрос к серверу
Вызов посерединеfunc(*args, **kwargs)await call_next(request)
Где добавляют@timed над каждой функциейОдин раз, в main.py
Аналогия: охранник на входе в здание

Каждый, кто входит в здание, проходит мимо охранника — неважно, на какой этаж он идёт. Охранник записывает, кто и когда вошёл и кто и когда вышел. Ему не нужно стоять у каждого офиса. Middleware = охранник на входе, а маршруты = офисы.

Почему нужно правильно ждать?

Middleware передаёт запрос маршруту, а затем ждёт ответа. Но сервер получает много запросов одновременно. Если он «застрянет» и будет ждать каждый запрос до конца, все остальные будут стоять в очереди.

Аналогия: официант и кухня

Официант передаёт заказ на кухню. Если он стоит у кухни, пока блюдо не будет готово, все остальные столики его ждут. Хороший официант говорит «сообщите мне, когда блюдо будет готово» и продолжает принимать заказы у других столиков. Когда блюдо готово — он возвращается, забирает его и подаёт.

async и await

Именно для этого и нужен await. Внутри Middleware есть строка:

response = await call_next(request)
  • call_next(request) — передаёт запрос дальше: в подходящий маршрут (или в 404, если такого нет) и возвращает ответ. Это заказ на кухню.
  • await — «сообщите мне, когда блюдо будет готово»: подождать ответа маршрута, а тем временем сервер свободен обрабатывать другие запросы. Когда ответ готов, код продолжается со следующей строки.
  • async def — await можно писать только внутри функции, определённой через async def. Поэтому Middleware определяется так.
Сколько об этом нужно знать на курсе?

Только этот шаблон: async def вверху и await перед call_next. Наши маршруты остаются обычным def — FastAPI сам их обрабатывает. Как сервер управляет ожиданием за кулисами (цикл событий, event loop, asyncio), мы изучим позже в треке.

request и response

ОбъектЧто в нём (что будем использовать)
request.method"GET", "POST"…
request.url.pathПуть, например "/students/2"
response.status_codeКод ответа: 200, 404…
response.headersЗаголовки ответа — работают как словарь

Синтаксис

from fastapi import FastAPI, Request

app = FastAPI()

@app.middleware("http")
async def my_middleware(request: Request, call_next):
    # ... before the route
    response = await call_next(request)     # wait for the route's response
    # ... after the route
    return response                         # always pass the response on
  • @app.middleware("http") — регистрирует функцию как Middleware для каждого HTTP-запроса.
  • request: Request — Request импортируется из fastapi.
  • return response — обязательно. Без этого клиент не получит ответа.

Первый пример кода

Лог и измерение времени для каждого запроса в API студентов. В начале students_memory.py (из предыдущего урока) — мы только добавляем, маршруты не меняются:

import logging
import time

from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

app = FastAPI(title="Students API")

@app.middleware("http")
async def log_requests(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    ms = round((time.time() - start) * 1000, 1)
    logging.info(request.method + " " + request.url.path + " -> "
                 + str(response.status_code) + " (" + str(ms) + " ms)")
    return response

# ... students, StudentIn and all the routes stay exactly the same

Запустите (uvicorn students_memory:app --reload) и отправьте запросы из Swagger (/docs). Лог в терминале:

INFO GET /students -> 200 (0.7 ms)
INFO GET /students/99 -> 404 (0.5 ms)
INFO POST /students -> 201 (1.3 ms)
INFO POST /students -> 400 (0.6 ms)
INFO GET /students/abc -> 422 (0.5 ms)

Разбор кода построчно

КодОбъяснение
logging.basicConfig(...)Настройка лога, один раз (уроки логирования).
@app.middleware("http")Следующая функция будет выполняться при каждом запросе.
async def log_requests(request: Request, call_next):Запрос и call_next — способ продолжить к маршруту.
start = time.time()До маршрута: начинаем измерение.
response = await call_next(request)Выполнить маршрут и дождаться ответа.
ms = round((time.time() - start) * 1000, 1)После: сколько прошло времени, в миллисекундах.
logging.info(...)Одна строка на каждый запрос: метод, путь, код и время. Также 404, 400 и 422 — потому что Middleware видит окончательный ответ, даже когда маршрут вообще не выполнялся.
return responseПередать ответ клиенту.

Обратите внимание: время очень короткое — данные лежат в списке в памяти. Когда мы подключим сервер к базе данных, увидим, как время растёт, — и такое измерение — именно способ обнаружить, где сервер «теряет» время.

Ещё один пример

Заголовок для каждого ответа

Middleware может и менять ответ. Здесь мы добавляем к каждому ответу заголовок с именем X-Process-Time со временем, которое заняла обработка:

@app.middleware("http")
async def add_process_time(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    ms = round((time.time() - start) * 1000, 1)
    response.headers["X-Process-Time"] = str(ms)
    return response

В Swagger в разделе Response headers теперь появляется:

x-process-time: 0.7
  • response.headers[...] = ... — как добавление ключа в словарь.
  • X- в начале имени — соглашение для собственного заголовка, который не входит в стандарт HTTP.
  • На одном сервере может быть несколько Middleware — каждый запрос проходит через все.

Когда не Middleware?

Middleware подходит для того, что нужно каждому запросу, — лог, измерение времени, заголовки. Правило одного маршрута — например, «минимальный возраст 16» в POST /students — принадлежит самому маршруту, а не коду, который выполняется на всём сервере.

Типичная ошибка

Ошибка 1: забыть return response.

@app.middleware("http")
async def log_requests(request: Request, call_next):
    response = await call_next(request)
    logging.info(request.url.path)

Middleware возвращает None, и клиент получает 500 — при каждом запросе, потому что Middleware выполняется на всех.

Ошибка 2: забыть await.

response = call_next(request)

Без await вы не ждёте маршрута — и response не является ответом. Сервер падает на каждом запросе. await перед call_next — всегда.

Ошибка 3: def вместо async def. Без async внутри функции нельзя использовать await: SyntaxError: 'await' outside async function.

Ошибка 4: логика маршрута внутри Middleware. if request.url.path == "/students" and ... — Middleware, которое проверяет конкретные пути, обычно признак того, что код принадлежит маршруту.

Практика

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

Упражнение 1: Что выполняется первым?

@app.middleware("http")
async def trace(request: Request, call_next):
    print("before", request.method, request.url.path)
    response = await call_next(request)
    print("after", response.status_code)
    return response


@app.get("/hello")
def hello():
    print("inside the route")
    return {"message": "hi"}

Вы отправляете GET /hello. Что будет напечатано и в каком порядке? А что будет напечатано для GET /nothing — несуществующего пути?

Показать решение
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

app = FastAPI()


@app.middleware("http")
async def trace(request: Request, call_next):
    print("before", request.method, request.url.path)
    response = await call_next(request)
    print("after", response.status_code)
    return response


@app.get("/hello")
def hello():
    print("inside the route")
    return {"message": "hi"}


client = TestClient(app)
client.get("/hello")
client.get("/nothing")
before GET /hello
inside the route
after 200
before GET /nothing
after 404
  • Middleware выполняется до маршрута, await call_next(request) запускает маршрут, а затем мы продолжаем после — с ответом на руках.
  • Запрос к несуществующему пути тоже проходит через Middleware: маршрута для выполнения нет, FastAPI возвращает 404 — и Middleware это тоже видит.

Упражнение 2: Лог для каждого запроса

Напишите Middleware с именем log_requests, которое пишет в лог на уровне INFO одну строку на каждый запрос: метод, путь и код ответа — в формате ожидаемого вывода.

Используйте это маленькое приложение: список с одним студентом, GET /students и GET /students/{student_id} (который возвращает 404 для несуществующего студента). Настройте лог так, чтобы строки печатались на экран:

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
logging.getLogger("httpx").setLevel(logging.WARNING)   # hide the test client's own log lines

students = [{"id": 1, "name": "Dana"}]

Чтобы проверить без запуска сервера, отправьте три запроса с помощью TestClient из FastAPI — инструмента, который отправляет запросы прямо в приложение из кода Python:

from fastapi.testclient import TestClient

client = TestClient(app)
client.get("/students")
client.get("/students/1")
client.get("/students/99")

Ожидаемый вывод:

INFO GET /students -> 200
INFO GET /students/1 -> 200
INFO GET /students/99 -> 404
Показать решение
import logging
import sys

from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
logging.getLogger("httpx").setLevel(logging.WARNING)   # hide the test client's own log lines

app = FastAPI()
students = [{"id": 1, "name": "Dana"}]


@app.middleware("http")
async def log_requests(request: Request, call_next):
    response = await call_next(request)
    logging.info(request.method + " " + request.url.path + " -> " + str(response.status_code))
    return response


@app.get("/students")
def list_students():
    return students


@app.get("/students/{student_id}")
def get_student(student_id: int):
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail="student not found")


client = TestClient(app)
client.get("/students")
client.get("/students/1")
client.get("/students/99")
INFO GET /students -> 200
INFO GET /students/1 -> 200
INFO GET /students/99 -> 404

(stream=sys.stdout и строка с httpx нужны только для проверки через TestClient. На настоящем сервере достаточно basicConfig с level и format.)

Одна строка лога в одном месте — и ни один маршрут не изменился. Маршрут, добавленный завтра, тоже автоматически попадёт в лог.

Упражнение 3: Заголовок со временем обработки

Напишите Middleware, которое измеряет, сколько заняла обработка запроса, и добавляет к ответу заголовок с именем X-Process-Time со временем в миллисекундах. В приложении один маршрут — GET /, который возвращает {"message": "hello"}.

Проверьте, что заголовок приходит, — отправьте запрос через TestClient (инструмент, который отправляет запросы прямо в приложение из кода Python) и проверьте заголовки ответа:

from fastapi.testclient import TestClient

client = TestClient(app)
response = client.get("/")
print(response.status_code)
print("Has X-Process-Time:", "x-process-time" in response.headers)

Ожидаемый вывод:

200
Has X-Process-Time: True
Показать решение
import time

from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

app = FastAPI()


@app.middleware("http")
async def add_process_time(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    ms = round((time.time() - start) * 1000, 1)
    response.headers["X-Process-Time"] = str(ms)
    return response


@app.get("/")
def home():
    return {"message": "hello"}


client = TestClient(app)
response = client.get("/")
print(response.status_code)
print("Has X-Process-Time:", "x-process-time" in response.headers)
200
Has X-Process-Time: True
  • time.time() до и после — точно как в декораторе, который измеряет время.
  • response.headers работает как словарь. Заголовки не зависят от регистра букв, поэтому x-process-time находит X-Process-Time.

Упражнение 4: async и await

@app.middleware("http")
async def log_requests(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    ms = round((time.time() - start) * 1000, 1)
    logging.info(request.url.path + " (" + str(ms) + " ms)")
    return response
  1. Что делает строка response = await call_next(request)? Объясните с помощью аналогии с официантом и кухней.
  2. Что делает сервер, пока await ждёт?
  3. Почему функция определена через async def, а не через def?
  4. Должны ли наши маршруты (list_students, get_student) тоже быть async def?
Показать решение
  1. call_next(request) передаёт запрос маршруту — это заказ на кухню. await — «сообщите мне, когда блюдо будет готово»: подождать ответа маршрута, и когда он готов, код продолжается на следующей строке — с ответом в response.
  2. Обрабатывает другие запросы. Как официант, который не стоит у кухни, а продолжает принимать заказы у других столиков, — сервер не «застревает» на одном запросе.
  3. Потому что await можно писать только внутри функции, определённой через async def. С обычным def Python вообще не допустит эту строку: SyntaxError: 'await' outside async function.
  4. Нет. Маршруты остаются обычным def — FastAPI сам их обрабатывает. На этом курсе async и await встречаются только в Middleware, в этом шаблоне.

Упражнение 5: Что не так?

@app.middleware("http")
def log_requests(request: Request, call_next):
    response = call_next(request)
    logging.info(request.url.path)

Найдите три проблемы. И когда использовать Middleware неправильно?

Показать решение
  1. def вместо async def — Middleware в FastAPI должно быть async def.
  2. Не хватает await перед call_next(request) — без него вы не ждёте маршрута, и response — не ответ.
  3. Не хватает return response — Middleware должно вернуть ответ дальше. Без этого клиент ничего не получит, и сервер падает.

Когда не нужно: Middleware подходит для того, что нужно каждому запросу, — лог, измерение времени, заголовки. Бизнес-правило одного маршрута — например, «минимальный возраст 16» в POST /students — принадлежит самому маршруту (или функции проверки), а не Middleware, которое выполняется на всём сервере.

Итоги

  • Middleware = код, который оборачивает каждый запрос: до маршрута и после него. Как декоратор — для всего сервера.
  • @app.middleware("http") + async def + await call_next(request) + return response.
  • До call_next — то, что нужно перед запросом (например, начать измерение). После — ответ на руках: код, заголовки, время.
  • Применения: лог для каждого запроса, измерение времени, заголовки для каждого ответа.
  • Правила одного маршрута — в маршруте, а не в Middleware.

Официальная документация