Фоновые задачи FastAPI: когда BackgroundTasks уже недостаточно

Как выбрать между BackgroundTasks и очередью: сохранение заданий, идемпотентность, отдельный воркер и проверки восстановления FastAPI.

Фоновые задачи FastAPI: когда BackgroundTasks уже недостаточно

Пользователь загрузил документ, а API должен распознать текст, обратиться к модели и собрать отчёт. Держать HTTP-запрос открытым до конца неудобно: клиент может отключиться, прокси — завершить ожидание, а повтор запроса — создать вторую задачу.

В FastAPI есть BackgroundTasks. Он помогает вернуть ответ до завершения дополнительной работы, но для важного задания одного этого механизма недостаточно. Разберём границу между локальной фоновой функцией и задачей, которая должна пережить перезапуск.

Что действительно делает BackgroundTasks

BackgroundTasks запускает добавленную работу после отправки ответа в процессе приложения. Он не создаёт отдельное хранилище заданий и не обещает восстановление после аварии процесса. Документация FastAPI также отделяет этот механизм от инструментов для тяжёлых распределённых задач.

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

import logging

from fastapi import BackgroundTasks, FastAPI

app = FastAPI()
logger = logging.getLogger("uvicorn.error")


def record_demo_event():
    logger.info("Demo background operation completed")


@app.get("/health")
async def health():
    return {"status": "ok"}


@app.post("/demo")
async def demo(tasks: BackgroundTasks):
    tasks.add_task(record_demo_event)
    return {"message": "Ответ отправлен; диагностическая операция выполнится следом"}

Это демонстрация порядка исполнения, а не надёжная очередь. Не используйте лог как единственный реестр принятых бизнес-задач.

Как принять задачу и не потерять её

Для генерации отчёта удобен следующий контракт:

  1. POST /reports проверяет пользователя, входные данные и ключ идемпотентности.
  2. Сервер сохраняет задание и его исходные данные в постоянном хранилище.
  3. Только после успешного сохранения возвращает 202 Accepted и job_id.
  4. Воркер получает задание, выполняет работу и записывает результат либо причину ошибки.
  5. GET /reports/{job_id} возвращает статус только владельцу задания.

Это схема API, которую нужно реализовать в приложении, а не готовые маршруты Inquir. Минимальные состояния — queued, running, succeeded, failed. Для зависшего running нужен срок аренды задания или другой способ вернуть его в обработку после смерти воркера.

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

Повтор должен быть безопасным

Предположим, воркер создал отчёт во внешнем сервисе, но упал до записи результата. Очередь выдаст задание снова. Без защиты приложение повторно потратит ресурсы или создаст вторую сущность.

Храните ключ операции, проверяйте уже сохранённый результат и передавайте идемпотентный ключ внешнему API, если оно его поддерживает. Ограничьте число попыток, задайте задержку между ними и отделите временную сетевую ошибку от невалидных входных данных. Бесконечный повтор ошибки авторизации не исправит ключ доступа.

Не обещайте «ровно один запуск» только потому, что добавили очередь. Полезная гарантия для пользователя — один бизнес-результат при возможных повторных попытках. Подробнее об этом — идемпотентность фоновых задач.

Как разместить FastAPI и обработчик

API принимает HTTP-трафик; длительный воркер работает постоянно и забирает задания независимо от запросов. Разделение позволяет выпускать и масштабировать их отдельно. Общими остаются постоянное хранилище, формат задания и правила доступа.

Для HTTP-приложения точка запуска может выглядеть так:

uvicorn main:app --host 0.0.0.0 --port 8080

Установите FastAPI и Uvicorn при сборке, зафиксируйте версии зависимостей, задайте в платформе тот же порт 8080 и health-путь /health. Не включайте --reload в рабочем релизе. Привязка к 127.0.0.1 внутри контейнера не позволит внешнему маршрутизатору обращаться к процессу обычным способом.

Начните с одного Uvicorn-процесса на контейнер и измерьте память и задержки. Несколько workers могут повысить параллелизм, но каждый процесс имеет собственную память и подключения. Например, 4 процесса с пулом до 10 соединений каждый способны открыть до 40 соединений с базой — ещё до добавления второй реплики. Это расчёт конфигурации, а не измерение нагрузки. См. процессы Uvicorn в документации FastAPI.

В Inquir FastAPI размещается как контейнерное HTTP-приложение. Для многошаговой обработки можно рассмотреть фоновые задачи и пайплайны. Существующий Python-код не становится шагом пайплайна автоматически: нужно подготовить обработчик, входные данные и передачу результата по контракту платформы.

Проверка, которая важнее зелёного health check

На тестовом окружении примите задание, затем остановите воркер до его завершения. После восстановления проверьте, что задача не исчезла, статус обновился, а внешний эффект не продублировался. Повторите одинаковый запрос с одним ключом идемпотентности и убедитесь, что второй пользователь не может прочитать чужой job_id.

Измеряйте не только время HTTP-ответа, но и возраст самого старого задания, число повторов и время до готового результата. Быстрый 202 ничего не говорит о работоспособности очереди, если за ним задания сутками остаются в ожидании.