Фоновые задачи в FastAPI: типичные ошибки и как их избежать
BackgroundTasks в FastAPI выглядит просто — передал функцию, она выполнится после ответа. На практике задачи теряются при перезапуске, блокируют event loop и молча падают без логов. Разбираем четыре частых ошибки и подходы, которые их закрывают.

Почему BackgroundTasks ломается не сразу
FastAPI предлагает встроенный механизм BackgroundTasks — передаёте callable, и он выполняется после того, как клиент получил ответ. В документации это три строки кода. В проде — источник багов, которые проявляются под нагрузкой, при деплое или при первом сбое внешнего сервиса.
Проблема не в самом классе: он делает ровно то, что обещает. Проблема в ожиданиях: разработчик воспринимает его как очередь задач, хотя это просто отложенный вызов внутри того же процесса. Ниже — четыре ошибки, которые встречаются чаще всего, и конкретные способы с ними справиться.
Ошибка 1. Синхронная блокирующая функция в фоновой задаче
FastAPI работает поверх Starlette, а Starlette запускает фоновые задачи после отправки ответа, но внутри того же event loop (для асинхронных задач) или в тредпуле (для синхронных). Если вы передаёте в BackgroundTasks.add_task() обычную синхронную функцию, которая делает тяжёлый IO или CPU-bound работу, Starlette выполнит её в тредпуле по умолчанию. Звучит безопасно — но тредпул общий и ограниченный.
Когда несколько эндпоинтов одновременно ставят тяжёлые синхронные задачи, пул забивается. Новые запросы, которым тоже нужен тредпул (например, синхронные зависимости), начинают ждать. Latency растёт, хотя CPU свободен.
Как выглядит проблемный код
# FastAPI 0.100+ / Python 3.11+
import time
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def generate_report(user_id: int) -> None:
"""Синхронная функция: тяжёлый запрос к БД + формирование PDF."""
time.sleep(30) # имитация долгой работы
@app.post("/reports")
async def request_report(
user_id: int,
bg: BackgroundTasks,
) -> dict:
bg.add_task(generate_report, user_id)
return {"status": "accepted"}
Каждый вызов generate_report занимает поток из пула на 30 секунд. При пуле по умолчанию в 40 потоков достаточно 40 одновременных запросов, чтобы заблокировать всё приложение.
Что делать
- Если задача IO-bound — перепишите её как
async defи используйте асинхронные библиотеки (httpx,asyncpg,aiofiles). Starlette вызовет её черезawait, без тредпула. - Если задача CPU-bound — не используйте
BackgroundTasksвовсе. Отправьте её в отдельный процесс черезProcessPoolExecutorили вынесите в очередь задач. - Если переписать на async нельзя прямо сейчас — ограничьте параллелизм через
asyncio.Semaphoreи явно запускайте задачу в отдельномThreadPoolExecutorс контролируемым размером.
Ошибка 2. Потеря задач при перезапуске
BackgroundTasks не персистентен. Задача живёт в памяти процесса. Если uvicorn перезапустился (деплой, OOM kill, сигнал от оркестратора) — задача исчезла. Ни повтора, ни лога, ни уведомления.
Это нормальное поведение для вещей, потеря которых допустима: отправка метрики, запись в лог. Но если задача — генерация счёта или отправка письма с подтверждением, потеря превращается в баг, о котором узнают от пользователя.
BackgroundTasks — это fire-and-forget внутри процесса. Если задачу нельзя потерять, она не должна жить только в памяти воркера.
Что делать
Для задач, которые не должны теряться, используйте внешнюю очередь: Celery, arq, Dramatiq, SAQ — или хотя бы запись в таблицу БД с последующей обработкой воркером. Выбор зависит от инфраструктуры, которая у вас уже есть.
Ошибка 3. Молчаливые исключения
Если фоновая задача выбрасывает исключение, клиент уже получил ответ — ему не вернётся 500. Starlette логирует ошибку через стандартный логгер, но если вы не настроили обработку логов, трейсбек уходит в stderr контейнера и тонет среди тысяч других строк. Задача не повторяется.
Типичный сценарий: задача шлёт запрос во внешний API, получает таймаут, падает, и никто об этом не знает — пока пользователь не спросит, почему не пришло письмо.
Что делать
# Обёртка с обработкой ошибок и ретраем
import logging
import asyncio
from typing import Any, Callable, Coroutine
logger = logging.getLogger("bg_tasks")
async def safe_task(
coro_fn: Callable[..., Coroutine[Any, Any, None]],
*args: Any,
retries: int = 2,
delay: float = 5.0,
**kwargs: Any,
) -> None:
"""Запускает асинхронную задачу с повторами и логированием."""
for attempt in range(1, retries + 2):
try:
await coro_fn(*args, **kwargs)
return
except Exception:
logger.exception(
"Фоновая задача %s упала, попытка %d/%d",
coro_fn.__name__,
attempt,
retries + 1,
)
if attempt <= retries:
await asyncio.sleep(delay)
logger.error(
"Фоновая задача %s не выполнена после %d попыток",
coro_fn.__name__,
retries + 1,
)
Передавайте safe_task в add_task вместо голой корутинной функции: bg.add_task(safe_task, send_confirmation_email, user_id=user_id). Это не заменяет полноценную очередь, но хотя бы делает ошибки видимыми и даёт шанс на повтор.
Ошибка 4. Использование объектов запроса после ответа
Фоновая задача выполняется после того, как Starlette отправила ответ и закрыла ASGI-скоуп запроса. Если задача обращается к объекту Request, его state, или к зависимостям, которые привязаны к жизненному циклу запроса (например, сессия БД из Depends), — поведение не определено. Сессия может быть уже закрыта, а объект запроса — частично очищен.
Как проявляется
sqlalchemy.orm.exc.DetachedInstanceError— ORM-объект отвязан от сессии.RuntimeError: Session is closed— асинхронная сессия уже завершена.- Чтение из
request.stateвозвращаетNoneили выбрасываетAttributeError.
Что делать
Передавайте в фоновую задачу только примитивы и простые структуры данных: int, str, dict, Pydantic-модели. Если задаче нужна БД — пусть она сама создаёт сессию внутри себя, а не наследует сессию из эндпоинта.
Сравнение подходов к фоновым задачам
| Критерий | BackgroundTasks | asyncio.create_task | Celery / arq / Dramatiq |
|---|---|---|---|
| Персистентность | Нет | Нет | Да (брокер) |
| Ретраи из коробки | Нет | Нет | Да |
| Отдельный процесс | Нет | Нет | Да |
| Мониторинг | Только логи | Только логи | Dashboard, метрики |
| Инфраструктура | Ничего | Ничего | Redis / RabbitMQ |
| Когда подходит | Лёгкие fire-and-forget задачи | Параллельный IO внутри запроса | Критичные или тяжёлые задачи |
asyncio.create_task упомянут для полноты: его иногда используют вместо BackgroundTasks, но он ещё опаснее — задача не привязана к жизненному циклу ответа, и при определённых условиях может быть собрана сборщиком мусора до завершения.
* Fire-and-forget — паттерн, при котором вызывающая сторона отправляет задачу и не ждёт результата. Удобно для побочных эффектов (логирование, уведомления), опасно для операций, результат которых важен.
Когда что выбирать
- BackgroundTasks — отправить аналитическое событие, записать лог, сбросить кеш. Потеря одной задачи при деплое некритична.
- Внешняя очередь — отправка email, генерация документов, любая операция, по которой пользователь ожидает результат. Потеря задачи = баг в бизнес-логике.
- Гибрид: эндпоинт пишет задание в БД (статус «pending»),
BackgroundTasksпытается его выполнить. Если процесс упал — отдельный воркер или cron подбирает незавершённые задания. Не требует Redis, работает с любой СУБД.
Что делать дальше
Проведите аудит текущих add_task вызовов в проекте. Для каждого ответьте на два вопроса: задача блокирующая или асинхронная? Что произойдёт, если она потеряется? Если хотя бы одна задача блокирующая и критичная — это первый кандидат на вынос в очередь. Метрику, за которой стоит следить: количество задач в состоянии «pending» дольше N секунд. Если оно растёт — воркер не справляется или задачи теряются.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Starlette BackgroundTask — официальная документация, FastAPI Background Tasks — официальная документация
Частые вопросы
Можно ли использовать BackgroundTasks для отправки email?
BackgroundTasks блокирует event loop?
Чем BackgroundTasks отличается от asyncio.create_task?
Что будет, если фоновая задача упадёт с исключением?
Работает ли BackgroundTasks при нескольких воркерах uvicorn?
Как передать сессию БД в фоновую задачу?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.