Жизненный цикл запроса: луковица middleware, резолв зависимостей, lifespan и где всплывают ошибки
uvicorn принимает, middleware оборачивают внутрь (последний добавленный — внешний), роутер матчит, зависимости резолвятся с кэшем на запрос, yield-teardown — после отправки. Пулы живут в lifespan, BackgroundTasks занимают воркер, 422/500 всплывают на фиксированных слоях.
Новый сервис заказов блестяще прошёл стейджинг на половине запроса в секунду. Первый день продакшен-трафика — 50 rps — он пережил за одиннадцать минут. Postgres начал отказывать в соединениях с FATAL: sorry, too many clients already; pg_stat_activity показывал сотни простаивающих соединений, и все принадлежали подам сервиса заказов; каждый эндпоинт, трогавший базу, возвращал 500. Причиной были четыре невинные строки: зависимость, делавшая pool = await asyncpg.create_pool(...) и отдававшая его через yield. Зависимости выполняются на каждый запрос — так что каждый запрос платил ~300 мс за рукопожатие пула и парковал десяток свежих соединений, и при 50 rps потолок базы max_connections = 100 исчезал примерно за две секунды устойчивого трафика; стейджинг выживал только за счёт idle-таймаутов соединений. Лекарством был не Postgres побольше. Лекарством был перенос создания пула туда, где место процессным ресурсам, — в lifespan, один раз на воркер, — а зависимости осталась единственная работа, под которую она и скоупится: одолжить соединение на время запроса и вернуть его.
После этого урока ты сможешь точно определить, в какой lifecycle-скоуп помещать любой ресурс — пул соединений, HTTP-клиент, сессию на запрос, — и будешь знать, какой слой отвечает за какой тип ошибки, прежде чем тянуться к отладчику.
Путь внутрь: accept, scope, луковица, роут
uvicorn принимает TCP-соединение, парсит HTTP в ASGI-scope и вызывает внешнее приложение. Дальше запрос идёт по луковице: каждый middleware — это ASGI-приложение, оборачивающее следующее; код на пути внутрь, вызов вглубь, затем код на пути наружу, когда ответ проходит обратно. Порядок поэтому — архитектура, а порядок регистрации в Starlette сбивает с толку: add_middleware оборачивает текущий стек, так что последний добавленный middleware — внешний: он видит запрос первым, а ответ последним:
app.add_middleware(AuthMiddleware) # добавлен первым → внутренний
app.add_middleware(RequestIdMiddleware) # добавлен вторым → ВНЕШНИЙ, бежит первым
# запрос: RequestId → Auth → роутер → хендлер
# ответ: хендлер → Auth → RequestIdПорядок кодирует политику. Request-id и логирование — наружу, чтобы каждый запрос, включая отклонённые позже, был наблюдаем. Что идёт следующим — auth или rate-limiting — настоящее решение: пер-пользовательскому лимиту нужен уже отработавший auth (он ключуется на идентичности); пер-IP лимит ставят снаружи auth, чтобы неаутентифицированный флуд не жёг CPU на верификации токенов. За луковицей роутер матчит путь и метод, и матчинг передаёт управление самой недооценённой стадии конвейера — резолву зависимостей.
Зависимости: граф на запрос со скоупнутым teardown
FastAPI резолвит граф зависимостей хендлера заново на каждый запрос, с двумя свойствами продакшенного веса. Первое — кэширование: одна и та же зависимость, встречающаяся в графе запроса несколько раз, выполняется один раз, и результат делят все (use_cache=True — дефолт): get_current_user в хендлере, проверке прав и аудит-зависимости — это одна верификация токена, а не три. Второе — yield-зависимости как скоупнутые ресурсы: код до yield выполняется на входе, отданное значение инжектится, код после yield — teardown, который гарантированно выполнится, в том числе когда хендлер бросает исключение:
async def get_conn(request: Request):
async with request.app.state.pool.acquire() as conn: # одолжить из lifespan-пула
async with conn.transaction():
yield conn # здесь работает хендлер
# commit/rollback + возврат соединения — на размоткеУ тайминга teardown есть зубы: начиная с FastAPI 0.106 teardown yield-зависимостей выполняется после отправки ответа, но до запуска фоновых задач. Фоновая задача, потянувшаяся к сессии БД запроса, найдёт её закрытой — сессии для фоновой работы создаёт сама задача, а не одалживает у зависимости, чей teardown уже отработал.
В файле сначала идёт app.add_middleware(AuthMiddleware), затем app.add_middleware(RateLimitMiddleware). Какой middleware первым видит входящий запрос?
Lifespan владеет процессными ресурсами
Scope lifespan (жизненный цикл процесса — ASGI-событие, срабатывающее при старте и остановке воркера) из урока 1 — место, где строится всё, что живёт столько же, сколько процесс: пулы соединений, HTTP-клиенты, веса моделей, кэши. Они создаются один раз на воркер при старте, расшариваются через app.state и чисто закрываются при остановке:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pool = await asyncpg.create_pool(DSN, min_size=5, max_size=10)
yield # здесь приложение обслуживает запросы
await app.state.pool.close()
app = FastAPI(lifespan=lifespan)Инцидент из Хука — каноническая инверсия этого правила: пул на запрос вместо пула на процесс. Арифметику стоит держать под рукой: пул стоит рукопожатия соединения (~сотни мс на TLS + аутентификацию) и держит сокеты открытыми; у Postgres по умолчанию max_connections = 100. Один пул на 10 на воркер × 4 воркера × 2 пода = 80 соединений — осознанно и стабильно. Один пул на запрос при 50 rps = потолок пробит за секунды, и отказ приходит в виде чужих 500-х.
После ответа: фоновые задачи, стриминг и где всплывают ошибки
Прежде чем тянуться к BackgroundTasks, спроси себя: должна ли эта задача пережить деплой? Нужны ли ей ретраи при сбое? Если хоть одно «да» — ей здесь не место. BackgroundTasks выполняет функции после отправки ответа — на том же воркере. В этом и фича, и ловушка: клиент разблокирован, а воркер — нет. Генератор отчёта на 60 секунд, повешенный фоновой задачей, занимает воркер после каждого ответа; при умеренном трафике цикл (или тредпул — для синхронных задач) навсегда занят не-запросной работой, а задачи умирают вместе с процессом на деплое — они в памяти, без ретраев, без наблюдаемости. Честное правило: фоновые задачи — для субсекундного fire-and-forget (поставить письмо в очередь, тронуть кэш); всё тяжелее уходит в настоящую очередь с ретраями и собственными воркерами. Для больших payload-ов StreamingResponse с асинхронным генератором отправляет чанки по мере производства — CSV-экспорт на 2 ГБ стримится в константной памяти вместо материализации в RAM.
Ошибки всплывают на фиксированных слоях, и знать, какой слой отвечает, — большая часть отладки. Отказ валидации происходит при резолве аргументов и зависимостей — хендлер не запускается, клиент получает 422 с адресами полей. HTTPException, брошенный в хендлере или зависимости, ловится exception-слоем фреймворка и рендерится своим статус-кодом, а teardown yield-зависимостей всё равно выполняется. Необработанное исключение распространяется наружу сквозь луковицу — каждый middleware на пути его видит (ваш логирующий middleware может его записать), — пока внешний слой server-error не ответит 500. Ответ в этот момент уже потерян; порядок ваших middleware решает лишь, кто успеет осмотреть тело.
Хендлер вешает через BackgroundTasks функцию генерации отчёта на 60 секунд и отвечает за 80 мс. Что происходит на самом деле?
- 01Проследите запрос от начала до конца: семантика порядка middleware, кэш зависимостей, тайминг teardown у yield-зависимостей и что выполняется после ответа.
- 02Назовите режимы отказа жизненного цикла: пул-на-запрос, злоупотребление BackgroundTasks и три места, где всплывают ошибки.
Жизненный цикл — это луковица с расписанием. uvicorn превращает байты в ASGI-scope и отдаёт его внешнему middleware — а это последний зарегистрированный, потому что add_middleware оборачивает текущий стек; из-за этой инверсии порядок auth-против-rate-limit — пункт код-ревью, а не вопрос стиля. Роутер матчит, и FastAPI резолвит граф зависимостей один раз на запрос: общие узлы выполняются однажды благодаря дефолтному кэшу, а yield-зависимости обрамляют хендлер setup-ом и гарантированным teardown-ом — teardown-ом, который срабатывает после отправки ответа и до фоновых задач, и ровно поэтому фоновая задача никогда не должна одалживать сессию БД у запроса. Процессным ресурсам в этом графе вообще не место: пулы и клиенты строятся один раз в lifespan, живут на app.state и закрываются на остановке — зависимость с пулом-на-запрос есть инцидент номер один, пробивающий max_connections = 100 за секунды при 50 rps, пока стейджинг на половине запроса в секунду ничего не замечал. После ответа воркер всё ещё можно потерять: BackgroundTasks выполняются на нём, так что субсекундные толчки — нормально, а 60-секундные отчёты топят сервис; долговечная работа живёт в очереди, а экспорт на 2 ГБ стримится чанк за чанком в константной памяти. У ошибок постоянные адреса — 422 от валидации до хендлера, статус-коды от HTTPException, 500 от внешнего слоя для всего необработанного, — и порядок ваших middleware решает, кто увидит их по дороге наружу. Теперь, когда встретишь исчерпанный max_connections на слабом трафике, ты знаешь куда смотреть: внутрь зависимостей — нет ли там пула, который должен жить в lifespan.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.