Retry и идемпотентность: как не выполнить внешнюю операцию дважды
Модуль: Надёжная обработка фоновых задач в Python
Backend
Уровень: Middle+ / Senior
Стек: Python, PostgreSQL
16, SQLAlchemy 2.x, FastAPI, HTTPX
Чему ты научишься
После урока ты сможешь:
- объяснить, почему корректное резервирование задачи в PostgreSQL не гарантирует однократное выполнение операции во внешнем API;
- отличать таймаут ожидания ответа от подтверждённого отказа сервера;
- проектировать стабильный
Idempotency-Keyдля повторных попыток; - объяснить, как уникальное ограничение PostgreSQL защищает API от двух конкурентных запросов;
- выбрать безопасную политику retry и объяснить ограничения строгого и нестрогого
lease.
1. Задача обработана, но результат потерян
В предыдущих уроках мы научились резервировать фоновые задачи, освобождать зависшие задачи по
lease_until и защищаться от устаревшего воркера с помощью attempts.
Теперь задача #42 должна создать отчёт во внешнем сервисе:
POST /reports
Content-Type: application/json
{
"task_id": 42,
"report_type": "monthly"
}
Представим следующую последовательность:
- Worker A резервирует задачу #42:
status=processing,attempts=1. - Он вызывает
POST /reports. Внешний API успешно создаёт отчёт. - Worker A аварийно завершается до записи результата в нашу таблицу задач.
- Срок
leaseистекает; Worker B резервирует эту же задачу сattempts=2. - Worker B снова вызывает
POST /reports.
Вопрос перед экспериментом: сколько отчётов появится, если каждый вызов
POST создаёт новую запись?
Ключевая проблема: внешнее действие и запись
completedв нашу БД выполняются в разных транзакциях. Успех одной операции не означает, что успела завершиться другая.
Даже если claim_task() идеально предотвращает одновременный захват задачи, после
падения воркера задачу приходится обрабатывать повторно. Lease обеспечивает восстановление
обработки, а не защиту внешнего эффекта от дублирования.
2. Подготовка лабораторной
Используем уже созданные базу backend_training_lab и схему
backend_training. Новую базу или отдельного пользователя создавать не нужно.
Скачай приложенный к уроку архив лабораторной №6 и распакуй его. Из корня папки
lesson06_retry_idempotency используй уже подготовленное Python-окружение или создай
его, если это необходимо, и установи зависимости:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Создай только таблицы этого урока в существующей БД:
psql -d backend_training_lab -f schema.sql
Если используешь прежний DATABASE_URL, сохрани его. При необходимости укажи строку
подключения к той же БД в формате драйвера psycopg:
export DATABASE_URL='postgresql+psycopg://USER:PASSWORD@localhost:5432/backend_training_lab'
Для HTTP-экспериментов открой два терминала. В первом запусти учебный API:
uvicorn lesson06.api:app --host 127.0.0.1 --port 8001
Во втором запускай команды лабораторной. Для экспериментов lease и
lease-stale FastAPI не требуется.
Важно: каждый сценарий сбрасывает только
backend_training.lesson06_tasksиbackend_training.lesson06_reports. Не запускай разные сценарии одновременно: они используют одни учебные таблицы.
Как устроена учебная среда и почему это не полноценный внешний сервис?
FastAPI запускается отдельным процессом и самостоятельно открывает транзакции PostgreSQL. Для
простоты он использует ту же учебную БД, но другую таблицу —
lesson06_reports. Воркер записывает состояние своей задачи в
lesson06_tasks. Между их транзакциями нет общего атомарного COMMIT.
В production внешний API обычно находится в другом сервисе и нередко использует другую БД. Учебная модель сохраняет главное свойство: подтверждение внешнего эффекта и подтверждение завершения задачи независимы.
3. Эксперимент №1. Почему появляется дубликат?
Гипотеза: если первый POST уже выполнился, а воркер не сохранил
результат, следующий воркер создаст ещё один отчёт.
Запусти:
python -m lesson06.demo duplicate
В выводе последовательно найди три момента:
- После первого
POSTвнешний API показываетотчётов=1, но наша задача всё ещёprocessing,report_id=None. - После имитации истечения lease Worker B забирает ту же задачу:
attempts=2. - После второго
POSTвнешний API показываетотчётов=2.
Наблюдение: в результате одна логическая задача создала два независимых отчёта. После завершения Worker B наша задача ссылается только на второй результат.
Почему это произошло? У сервиса не было способа понять, что оба HTTP-запроса относятся к одной логической операции.
Что в лабораторной означает «воркер упал»?
Код не завершает процесс принудительно. Он намеренно пропускает вызов
complete_task() после первого успешного POST. Функция
expire_lease_for_lab() переносит срок lease в прошлое, чтобы не ждать 30 секунд.
Это управляемая имитация состояния после сбоя, а не тест аварийного завершения операционной
системы.
4. Идемпотентность и Idempotency-Key
Идемпотентность означает, что повторное выполнение одной и той же операции не приводит к дополнительному предусмотренному эффекту. Для нашего API это означает: несколько одинаковых запросов на создание одного отчёта должны дать один созданный отчёт.
Сам по себе метод POST этого не гарантирует. Но API может предоставить специальный
контракт: клиент передаёт идентификатор логической операции.
POST /reports
Idempotency-Key: report-task-42
Content-Type: application/json
{
"task_id": 42,
"report_type": "monthly"
}
Наш учебный API запоминает ключ и содержание операции:
- Новый ключ: создаёт отчёт, возвращает
HTTP 201,replayed=False. - Тот же ключ и то же содержание: возвращает ранее созданный
report_id,HTTP 200,replayed=True. - Тот же ключ и другое содержание: отклоняет запрос с
HTTP 409.
Важное правило: ключ относится к логической операции, а не к конкретному запуску воркера.
Задача #42, attempts=1 → report-task-42
Задача #42, attempts=2 → report-task-42
Задача #42, attempts=3 → report-task-42
Если формировать ключ как report-task-42-attempt-2, внешний сервис примет повтор за
новую операцию. В нашем случае такой ключ был бы ошибкой проектирования.
Одинаковый эффект не требует полностью одинакового HTTP-ответа: в лабораторной первый вызов
возвращает 201, а повторный — 200.
Это универсальный формат Idempotency-Key для всех API?
Нет. Здесь report-task-42 — простой идентификатор по контракту нашего
учебного сервиса. В реальной интеграции формат, область уникальности, срок хранения и правила
повторов определяются документацией провайдера. В частности, актуальный на дату урока проект
IETF описывает значение Idempotency-Key как строковое Structured Field, для
которого предусмотрен формат с кавычками. Наш mock API реализует упрощённый учебный контракт; не
нужно переносить этот формат заголовка в сторонний API без проверки его спецификации.
5. Эксперимент №2. Повторяем задачу с тем же ключом
Гипотеза: Worker B повторит HTTP-запрос, но второй отчёт не появится, если внешний API поддерживает дедупликацию по стабильному ключу.
python -m lesson06.demo idempotent
Сравни с предыдущим экспериментом:
| Событие | Без ключа | С ключом |
|---|---|---|
| Первый POST | Новый отчёт | Новый отчёт |
| Второй POST | Ещё один отчёт | Прежний отчёт |
| Количество отчётов | 2 | 1 |
| attempts нашей задачи | 2 | 2 |
Вывод: повторная обработка задачи всё ещё произошла. Но повторный внешний эффект предотвратил контракт идемпотентности API.
Рис. 2. Idempotency-Key предотвращает повторное создание отчёта при повторной обработке задачи.
6. HTTP timeout: неизвестный результат
Представь, что воркер отправил POST и не дождался ответа. Можно ли утверждать, что
отчёт не создан?
Нет. Таймаут сообщает, что клиент не получил ожидаемое сетевое событие в установленный срок. Он сам по себе не подтверждает ни успех, ни отмену операции на сервере.
В следующем эксперименте порядок намеренно задан так:
- API создаёт отчёт и выполняет
COMMIT. - API задерживает HTTP-ответ на
900 мс. - Клиент перестаёт ждать через
150 мси получает timeout.
Гипотеза: несмотря на таймаут, отчёт уже находится в таблице API. Повтор с тем же ключом вернёт его идентификатор.
python -m lesson06.demo timeout
Проверь вывод: после таймаута учебная диагностика показывает отчётов=1, а следующий
вызов с прежним ключом получает HTTP 200 и прежний report_id. Затем
задача успешно переходит в completed.
Главная мысль:
timeout— это отсутствие своевременного подтверждения у клиента, а не доказательство отсутствия эффекта на сервере.
Почему в лабораторной мы видим отчёт после таймаута, а в реальном сервисе часто нет?
Функция snapshot() для обучения читает обе таблицы одной учебной БД. В реальной
интеграции клиент обычно не имеет прямого доступа к БД провайдера. Поэтому восстановление после
неопределённого результата опирается на документированный механизм идемпотентности или API
проверки статуса операции, если он предоставлен.
7. Когда и как повторять HTTP-запрос?
Retry — повторная попытка после ошибки или неопределённого результата. Но
повторять любой POST автоматически опасно: без гарантий внешнего API это может
создать дубликат.
Для инженера важны два вопроса: можно ли повторять операцию безопасно и имеет ли смысл повторять её сейчас.
| Ситуация | Решение |
|---|---|
| Timeout или обрыв соединения | Исход может быть неизвестен. Повторять POST только при наличии надёжного
контракта идемпотентности либо подтверждения, что операция не применена. |
Временная недоступность, некоторые 5xx |
Рассмотреть ограниченный retry, если операция безопасна для повтора. Сам код
5xx не гарантирует отсутствие внешнего эффекта. |
429 и ограничение частоты |
Следовать контракту API и Retry-After, если он предоставлен; не создавать
поток немедленных повторов. |
| Ошибка валидации, неверные данные | Исправить запрос; слепой повтор тех же данных обычно бесполезен. |
409: прежний ключ, другой payload |
Не повторять неизменённый ошибочный запрос. Разобраться, почему один ключ назначен разным операциям. |
Production-политика retry обычно включает ограничение числа попыток, таймауты и backoff — увеличение паузы между повторными попытками. При нескольких воркерах полезен jitter — небольшая случайная вариация задержки, чтобы все клиенты не повторяли запрос синхронно.
В нашей лабораторной timeout повторяет запрос один раз, чтобы
изолированно показать механизм. Полноценный retry-планировщик, очереди и хранение политик попыток
здесь не реализуются.
Почему одного стабильного Idempotency-Key может быть недостаточно в production?
Нужно знать срок хранения ключа, обработку незавершённой операции, правила сравнения payload и поведение при одновременных запросах. Если провайдер удалил запись о ключе, старый запрос могут принять как новый. Кроме того, если сам внешний сервис вызывает третью систему, уникальная запись в его БД не обязательно делает идемпотентным этот третий побочный эффект.
8. Эксперимент №3. Два одновременных POST с одним ключом
Пока мы рассмотрели последовательные повторы. Но что, если два клиента отправят одинаковые запросы одновременно?
Наивный алгоритм выглядит так:
if not report_exists(idempotency_key):
create_report()
Он небезопасен: оба обработчика могут одновременно получить «отчёта нет», а затем оба создать новый ресурс.
В учебном API дедупликация происходит в PostgreSQL. Столбец idempotency_key имеет
ограничение UNIQUE, а вставка использует:
INSERT INTO backend_training.lesson06_reports
(task_id, report_type, idempotency_key, payload_hash)
VALUES (:task_id, :report_type, :key, :digest)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
Первый успешно вставивший запрос создаёт ресурс. Второй обнаруживает конфликт, после чего учебный API читает существующую запись и сверяет хеш содержания запроса. Эти действия выполняются в транзакции PostgreSQL.
Гипотеза: два одновременных запроса с одним ключом создадут один отчёт, а повтор
с тем же ключом и другим report_type получит 409.
python -m lesson06.demo concurrent
В выводе найди два результата: HTTP 201 и HTTP 200 с одинаковым
report_id. Затем проверь HTTP 409 при изменении
report_type.
Обрати внимание: здесь два параллельных HTTP-клиента, но нет двух захватов фоновой задачи. Эксперимент проверяет именно реализацию внешнего API.
Почему при ON CONFLICT DO NOTHING второй SELECT находит запись?
В учебном API используется стандартный READ COMMITTED. Конфликт уникальности может
зависеть от конкурентной вставки, ещё не видимой снимку самого INSERT. После
разрешения конфликта следующий SELECT получает новый снимок и может прочитать уже
зафиксированную запись. Это конкретное поведение PostgreSQL, а не универсальное правило всех
СУБД и всех уровней изоляции.
9. Lease и attempts: почему это другая защита
Вернёмся к нашей задаче. Защита HTTP-операции по ключу и защита записи результата в PostgreSQL решают разные проблемы:
Idempotency-Keyпомогает внешнему API не создавать повторный ресурс.attemptsне даёт устаревшему воркеру перезаписать результат новой попытки в нашей таблице.lease_untilопределяет срок, после которого зависшую задачу можно зарезервировать повторно.
Функция complete_task() сверяет attempts и, в зависимости от политики,
срок lease:
WHERE id = :task_id
AND status = 'processing'
AND attempts = :attempts
AND (
NOT :strict_lease
OR lease_until > clock_timestamp()
)
При strict_lease=True срок должен ещё действовать. При
strict_lease=False срок может истечь, но номер попытки обязательно должен оставаться
актуальным.
Эксперимент №4 — короткое сравнение. FastAPI не нужен.
python -m lesson06.demo lease
Сначала Worker A забирает задачу. Затем срок lease искусственно истекает, но никто не делает
повторный claim_task().
- Строгое завершение возвращает
False: срок истёк. - Нестрогое завершение возвращает
True: попытка всё ещё та же,attempts=1.
Почему в одном опыте можно выполнить обе проверки? Неуспешное строгое завершение не изменяет строку, поэтому нестрогая проверка выполняется на прежнем состоянии.
Дополнительный эксперимент: что изменится, если Worker B успел перезабрать задачу?
Запусти python -m lesson06.demo lease-stale. После повторного захвата номер
попытки станет attempts=2. Worker A сохранит старый токен с
attempts=1. Даже нестрогое завершение Worker A вернёт False, поскольку
попытка уже устарела. Worker B сможет завершить актуальную попытку.
В этих двух опытах report_id=101/102 — условные значения для проверки
SQL-условий, а не реальные отчёты внешнего API.
Выбор политики: строгий lease проще трактовать как ограничение права на завершение по времени. Нестрогая политика может принять поздний результат, если повторный захват ещё не произошёл. Но ни одна политика сама по себе не отменяет уже выполненный HTTP-запрос и не заменяет идемпотентность внешнего API.
10. Границы решения
В нашей учебной системе один и тот же запрос с одним ключом приводит к созданию не более одного
отчёта пока соответствующая запись и её уникальный ключ сохранены. Это не следует
называть безусловной гарантией exactly-once для всей распределённой обработки.
Перед использованием похожей схемы в production проверь:
- Контракт внешнего API: поддерживает ли оно идемпотентность, как долго живут ключи и что происходит при повторе с другим содержимым.
- Атомарность внутри API: вместе ли фиксируются эффект и сведения об идемпотентности; есть ли дополнительные побочные действия за пределами этой транзакции.
- Политику retry: какие ошибки повторяются, сколько раз и с какой задержкой.
- Устаревшие попытки: не перезапишет ли старый воркер актуальное состояние
задачи; при необходимости используем
attemptsкак fencing token. - Наблюдаемость: можно ли по логам связать
task_id,attempts,Idempotency-Keyи внешнийreport_id, не записывая чувствительные данные.
Не держи транзакцию резервирования задачи открытой на протяжении сетевого вызова. В лабораторной
claim_task() завершает свою транзакцию, затем выполняется HTTP, а
complete_task() открывает отдельную короткую транзакцию. Это не делает систему
атомарной между сервисами, но не удерживает блокировки PostgreSQL во время ожидания внешнего
ответа.
11. Проверь понимание
- Почему
attempts=2не означает, что во внешнем API должны существовать два отчёта? - Что именно неизвестно клиенту после HTTP-таймаута?
- Почему нельзя формировать
Idempotency-Keyиз номера попытки воркера? - Что обеспечивает
UNIQUE(idempotency_key), чего не обеспечивает параSELECT → INSERT? - В какой ситуации нестрогая политика разрешит
complete_task(), а строгая — запретит?
Попробуй ответить своими словами и привести хотя бы один случай, в котором выбранное решение перестанет быть безопасным.
Итог урока
Мы научились различать повторную попытку обработки и повторный внешний
эффект. Восстановление зависшей задачи требует возможности retry. Но безопасный retry
для POST возможен только тогда, когда его допускает контракт внешнего API или можно
надёжно установить, что исходная операция не была применена.
В нашей лабораторной стабильный Idempotency-Key и атомарная запись с уникальным
ограничением предотвращают создание второго отчёта. attempts и lease
независимо защищают жизненный цикл фоновой задачи. Для production важно правильно сочетать оба
механизма и понимать границы их гарантий.
Первичные источники и документация
- RFC 9110 — HTTP Semantics: идемпотентность методов и безопасность повторов.
- IETF Internet-Draft — Idempotency-Key: проект спецификации заголовка, не окончательный RFC на дату подготовки урока.
- PostgreSQL 16 — INSERT, ON CONFLICT.
- PostgreSQL 16 — Transaction Isolation.
- SQLAlchemy 2.x — Transactions and Engine.begin().
- HTTPX — Timeouts.