Идемпотентность API: Idempotency-Key, ретраи и почему exactly-once — иллюзия

Какие HTTP-методы идемпотентны, как устроить Idempotency-Key и хранение ответов, что делать с параллельными повторами, как ретраить с jitter и дедуплицировать сообщения.

14 мин чтениясистемный дизайн · api · надёжность · python

Коротко

  • Идемпотентная операция при повторе не меняет состояние сверх первого вызова. SET status = 'paid' можно повторять, balance = balance + 100 — нет.
  • Повторы неизбежны. Клиент не может отличить «запрос потерялся» от «потерялся ответ», поэтому ретраит. Сервер обязан это пережить.
  • Для неидемпотентных операций нужен ключ. Клиент генерирует Idempotency-Key, сервер атомарно занимает его, выполняет операцию и сохраняет ответ. Повтор получает тот же ответ, а не второй платёж.
  • Гонку решает уникальный индекс, а не SELECT перед INSERT. Параллельный повтор с тем же ключом получает 409, повтор с другим телом — 422.
  • Exactly-once доставки не бывает. Бывает at-least-once плюс дедупликация в чётко очерченной границе.

Почему повторы неизбежны

Клиент отправил POST /payments и через 10 секунд получил таймаут. Что произошло на сервере?

  1. Запрос не дошёл — платежа нет.
  2. Запрос дошёл, сервер упал посреди обработки — платёж мог создаться, а мог и нет.
  3. Платёж создан, но ответ потерялся на обратном пути — деньги списаны.

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

Повторяют не только клиенты. Ретраят балансировщики, API-шлюзы, сервисные сетки, SDK и сами пользователи, которые жмут «Оплатить» второй раз. Поэтому вопрос не «будут ли дубли», а «что сервер с ними сделает».

Идемпотентность HTTP-методов

RFC 9110 называет метод идемпотентным, если эффект нескольких одинаковых запросов на сервере такой же, как от одного. Важно: речь об эффекте, а не об ответе. Первый DELETE /orders/42 вернёт 204, второй — 404, но заказ в обоих случаях удалён.

МетодБезопасныйИдемпотентныйКомментарий
GETдадаТолько чтение. Счётчик просмотров — побочный эффект сервера
HEADдадаКак GET без тела
OPTIONSдада
PUTнетда«Положи ресурс в таком виде» — повтор даёт тот же результат
DELETEнетдаПовтор ничего не удаляет повторно
POSTнетнет«Создай ещё один» — повтор создаёт второй
PATCHнетне обязательно{"name": "Bob"} идемпотентен, {"op": "increment"} — нет

Две оговорки.

Спецификация — это обещание, а не гарантия. Ничто не мешает написать GET /send-email, который отправит письмо при каждом вызове. Браузер, прокси или краулер могут повторить GET без спроса, и письма полетят пачками. Идемпотентность нужно реализовать в коде, метод только сообщает о ней клиентам.

PUT идемпотентен, но не защищён от гонок. Два клиента одновременно делают PUT с разными данными — выигрывает последний, первый молча теряет изменения. Это другая проблема, её решают версии и условные запросы: If-Match с ETag, сервер отвечает 412, если ресурс успел измениться.

Как сделать операцию идемпотентной без ключа

Часто проще перепроектировать API, чем хранить ключи:

  • Абсолютные значения вместо приращений. PUT /cart/items/7 {"qty": 3} вместо POST /cart/items/7/increment.
  • Клиентский идентификатор ресурса. Клиент сам генерирует UUID и делает PUT /orders/{uuid}. Повтор упирается в первичный ключ.
  • Переход состояний с проверкой. UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'pending'. Второй вызов обновит ноль строк.
  • Версия ресурса. UPDATE ... WHERE version = ? отбрасывает устаревшие запросы.

Если ничего из этого не подходит — например, создание платежа во внешней системе, — нужен ключ идемпотентности.

Idempotency-Key: как устроен

Идея проста: клиент генерирует уникальный ключ на одну логическую операцию (обычно UUIDv4) и передаёт его в заголовке Idempotency-Key. При ретраях ключ тот же. Сервер запоминает результат первого выполнения и на повторы отдаёт его.

Так работают платёжные API: у Stripe заголовок Idempotency-Key поддерживают POST-запросы, а ключи хранятся не меньше 24 часов. В IETF есть черновик стандарта «The Idempotency-Key HTTP Header Field», он описывает тот же подход и коды ошибок. На ноябрь 2026 года это всё ещё черновик, а не RFC, так что детали у разных API отличаются.

Что сервер должен хранить по ключу:

ПолеЗачем
client_idКлюч уникален в пределах клиента, иначе чужие ключи столкнутся
keyСам ключ
fingerprintХэш тела запроса, чтобы поймать повтор ключа с другими параметрами
statusin_progress или completed
responseКод и тело ответа, которые вернём на повтор
created_atДля удаления по TTL

Ответы на разные ситуации:

СитуацияОтвет
Ключ новыйВыполнить операцию, сохранить ответ
Ключ есть, completed, тело совпадаетВернуть сохранённый ответ
Ключ есть, in_progress409 Conflict — первый запрос ещё идёт
Ключ есть, тело другое422 Unprocessable Content — ошибка клиента
Ключ обязателен, но не передан400 Bad Request

Где хранить

  • В той же базе, что и бизнес-данные. Лучший вариант: отметку completed и сам эффект (строку в payments) можно записать в одной транзакции. Тогда не бывает состояния «платёж есть, ключ не отмечен».
  • В Redis. Быстро и с TTL из коробки, но атомарности с базой нет. Если сервис упадёт между записью платежа и записью ответа в Redis, повтор создаст второй платёж. Подходит, когда эффект сам по себе идемпотентен или потеря редкого дубля не страшна.

TTL выбирают по окну ретраев клиента: если клиент повторяет максимум сутки, хранить ключи неделю бессмысленно. Типичные значения — от 24 часов до нескольких дней.

Гонки при параллельных повторах

Самая частая ошибка в реализации:

row = SELECT ... WHERE key = ? if row is None: create_payment() # два потока одновременно попадут сюда INSERT key

Клиент отправил запрос, через 2 секунды не дождался ответа и повторил. Первый запрос ещё выполняется, ключа в базе нет — второй тоже создаёт платёж. Проверка и вставка должны быть одной атомарной операцией. Это делает уникальный индекс: INSERT ... ON CONFLICT DO NOTHING в Postgres или INSERT OR IGNORE в SQLite. Вставка прошла — ключ наш, выполняем. Не прошла — кто-то уже занял ключ, смотрим его статус.

Ниже рабочий пример на SQLite: пять потоков одновременно шлют один запрос с одним ключом.

import hashlib import json import sqlite3 import threading import time DB_PATH = 'idem.db' def setup(): db = sqlite3.connect(DB_PATH) db.executescript(""" DROP TABLE IF EXISTS idempotency_keys; DROP TABLE IF EXISTS payments; CREATE TABLE idempotency_keys ( client_id TEXT NOT NULL, key TEXT NOT NULL, fingerprint TEXT NOT NULL, status TEXT NOT NULL, -- in_progress | completed response TEXT, created_at REAL NOT NULL, PRIMARY KEY (client_id, key) ); CREATE TABLE payments (id INTEGER PRIMARY KEY, amount INTEGER); """) db.commit() db.close() def fingerprint(body): raw = json.dumps(body, sort_keys=True).encode() return hashlib.sha256(raw).hexdigest() def create_payment(client_id, key, body): db = sqlite3.connect(DB_PATH, timeout=5, isolation_level=None) fp = fingerprint(body) try: # 1. Атомарно занимаем ключ: из параллельных вставок пройдёт одна db.execute('BEGIN IMMEDIATE') inserted = db.execute( 'INSERT OR IGNORE INTO idempotency_keys ' 'VALUES (?, ?, ?, ?, NULL, ?)', (client_id, key, fp, 'in_progress', time.time()), ).rowcount row = db.execute( 'SELECT fingerprint, status, response FROM idempotency_keys ' 'WHERE client_id = ? AND key = ?', (client_id, key), ).fetchone() db.execute('COMMIT') if not inserted: saved_fp, status, response = row if saved_fp != fp: return 422, {'error': 'ключ уже использован с другим телом'} if status == 'in_progress': return 409, {'error': 'запрос ещё выполняется'} return 200, json.loads(response) # повтор: отдаём сохранённое # 2. Выполняем операцию и сохраняем ответ в одной транзакции time.sleep(0.05) # имитация работы db.execute('BEGIN IMMEDIATE') payment_id = db.execute( 'INSERT INTO payments (amount) VALUES (?)', (body['amount'],) ).lastrowid response = {'payment_id': payment_id, 'amount': body['amount']} db.execute( 'UPDATE idempotency_keys SET status = ?, response = ? ' 'WHERE client_id = ? AND key = ?', ('completed', json.dumps(response), client_id, key), ) db.execute('COMMIT') return 201, response finally: db.close() setup() body = {'amount': 500, 'currency': 'RUB'} # Пять параллельных повторов с одним ключом results = [] threads = [ threading.Thread( target=lambda: results.append(create_payment('shop-1', 'k-42', body)) ) for _ in range(5) ] for t in threads: t.start() for t in threads: t.join() print(sorted(code for code, _ in results)) # Повтор после завершения и попытка подменить тело print(create_payment('shop-1', 'k-42', body)) print(create_payment('shop-1', 'k-42', {'amount': 900, 'currency': 'RUB'})) db = sqlite3.connect(DB_PATH) print('платежей:', db.execute('SELECT COUNT(*) FROM payments').fetchone()[0])

Вывод:

[201, 409, 409, 409, 409] (200, {'payment_id': 1, 'amount': 500}) (422, {'error': 'ключ уже использован с другим телом'}) платежей: 1

Один запрос выполнил работу, четыре параллельных получили 409, повтор после завершения — сохранённый ответ, подмена тела — 422. Платёж один.

Что пример упрощает

  • Сбой посреди операции. Если процесс упадёт после захвата ключа, запись навсегда останется in_progress, и все повторы будут получать 409. Нужен таймаут блокировки: запись in_progress старше N секунд можно перехватить. А при ошибке, которую клиент может повторить (таймаут downstream-сервиса), ключ лучше удалить, а не сохранять ошибку как окончательный ответ.
  • Какие ответы сохранять. Успех и ошибки валидации (4xx) — да: повтор с теми же данными даст тот же результат. 5xx, которые возникли до любого побочного эффекта, — обычно нет, иначе клиент никогда не сможет повторить.
  • Внешние вызовы. Если внутри операции вы зовёте платёжного провайдера, транзакция базы его не откатит. Передавайте провайдеру свой ключ идемпотентности, производный от входящего, — тогда повтор у провайдера тоже не создаст второй платёж. Длинные цепочки таких шагов разбивают на этапы с сохранением прогресса, как в паттерне Saga из статьи о микросервисах.
  • Fingerprint. Хэшируйте нормализованное тело: порядок полей в JSON не должен давать другой отпечаток. Поэтому в примере sort_keys=True.

Ретраи: экспоненциальная задержка и jitter

Идемпотентный сервер — половина дела. Вторая половина — клиент, который ретраит так, чтобы не добить и без того больной сервис.

Что повторять. Таймауты, обрывы соединения, 502, 503, 504, 429. Не повторять 400, 401, 403, 404, 422: повтор даст ту же ошибку. Неидемпотентный POST без ключа не повторять вовсе — или повторять, только если запрос гарантированно не ушёл (ошибка при установке соединения).

Как часто. Повтор сразу после ошибки — худшая стратегия. Если сервис упал из-за перегрузки, тысяча клиентов мгновенно удвоит нагрузку. Нужна экспоненциальная задержка: base⋅2attemptbase \cdot 2^{attempt} с потолком.

Зачем jitter. Если все клиенты получили ошибку в один момент, с чистой экспонентой они вернутся тоже синхронно: через 100 мс, 200 мс, 400 мс — волнами. Случайный разброс размазывает волну по времени. В известном разборе от AWS (Marc Brooker, «Exponential Backoff And Jitter») лучше всего показали себя варианты full jitter и decorrelated jitter. Full jitter самый простой:

delay=random(0, min⁡(cap, base⋅2attempt))delay = random(0,\ \min(cap,\ base \cdot 2^{attempt}))

import random import time class TransientError(Exception): pass def call_with_retries(fn, max_attempts=5, base=0.1, cap=2.0): for attempt in range(max_attempts): try: return fn() except TransientError: if attempt == max_attempts - 1: raise # full jitter: случайная пауза от 0 до экспоненциального потолка delay = random.uniform(0, min(cap, base * 2 ** attempt)) print(f'попытка {attempt + 1} не удалась, ждём {delay:.3f} с') time.sleep(delay) random.seed(7) calls = {'n': 0} def flaky(): calls['n'] += 1 if calls['n'] < 3: raise TransientError('timeout') return 'ok' print(call_with_retries(flaky))
попытка 1 не удалась, ждём 0.032 с попытка 2 не удалась, ждём 0.030 с ok

Idempotency-Key генерируется до цикла ретраев и не меняется между попытками. Новый ключ на каждую попытку — частая ошибка, которая обнуляет всю защиту.

Ретраи, которые ломают систему

  • Умножение по слоям. Клиент делает 3 попытки, API-шлюз — 3, сервис к базе — 3. Одна ошибка базы превращается в 27 запросов. Ретраить лучше на одном уровне, обычно ближайшем к источнику ошибки или на самом внешнем.
  • Retry budget. Ограничьте долю повторов, например не больше 10% от успешных запросов. Когда сервис лежит целиком, ретраи только мешают ему подняться. Похожую роль играет circuit breaker.
  • Заголовок Retry-After. Если сервер вернул 429 или 503 с этим заголовком, ждите столько, сколько просят. Как сервер решает, когда отвечать 429, — в статье про rate limiter.
  • Общий дедлайн. Сумма попыток не должна превышать таймаут, который ждёт пользователь. Пятая попытка через 30 секунд, когда клиент давно ушёл, только тратит ресурсы.

Дедупликация в очередях

В брокерах сообщений та же проблема, только ретраят продюсер и брокер. Гарантии доставки, коммит offset и таблица обработанных сообщений подробно разобраны в статье об очередях сообщений, здесь — коротко, что общего с API.

Дедупликация бывает на двух сторонах:

  • На входе в брокер. Продюсер передаёт идентификатор сообщения, брокер отбрасывает повторы в течение окна. У SQS FIFO это MessageDeduplicationId и окно 5 минут, у NATS JetStream — заголовок Nats-Msg-Id и настраиваемое окно, у Kafka — идемпотентный продюсер, который отсекает повторы внутри одной сессии продюсера. Все эти механизмы ограничены окном или сессией: повтор после рестарта сервиса через час брокер не узнает.
  • У потребителя. Единственное место, где дедупликация надёжна до конца. Потребитель записывает message_id в таблицу с уникальным ключом в той же транзакции, что и эффект. Это ровно та же схема, что с Idempotency-Key, только ключ — идентификатор события.

Откуда брать идентификатор: генерировать при создании события (как event_id в паттерне outbox) или выводить из бизнес-данных — order_id + тип события. Плохой вариант — offset или номер доставки: при повторной публикации у дубля будет другой offset.

Exactly-once — иллюзия

Фраза «exactly-once delivery» обещает невозможное. Сеть может потерять подтверждение, и отправитель не узнает, дошло ли сообщение. Выбор только из двух:

  • at-most-once — не повторять и иногда терять;
  • at-least-once — повторять и иногда дублировать.

То, что продают как exactly-once, — это exactly-once обработка (effectively-once): at-least-once доставка плюс дедупликация в определённой границе. Транзакции Kafka дают её внутри Kafka: прочитали, записали, закоммитили offset атомарно. Но письмо клиенту, вызов платёжного API или запись во внешнюю базу в эту транзакцию не входят. Для них идемпотентность придётся строить самим.

На собеседовании это стоит проговорить явно: «Доставка at-least-once, обработчики идемпотентны, дедупликация по event_id в той же транзакции, что и эффект». Это показывает, что вы понимаете, где именно граница гарантии. Связь с компромиссами распределённых систем подробнее — в статье о теореме CAP.

Как отвечать на собеседовании

Задачи, где идемпотентность всплывает почти всегда: платёжная система, оформление заказа, отправка уведомлений, бронирование, перевод денег между счетами. План ответа:

  1. Назвать проблему. Таймаут не значит «не выполнилось», клиент будет повторять.
  2. Сделать идемпотентным то, что можно. Абсолютные значения, клиентские идентификаторы, переходы состояний.
  3. Для остального — ключ. Кто генерирует (клиент), где хранится (в той же базе, что и эффект), сколько живёт (по окну ретраев), что возвращаем на повтор.
  4. Закрыть гонку. Уникальный индекс и статус in_progress, 409 на параллельный повтор.
  5. Описать клиента. Экспоненциальная задержка с jitter, лимит попыток, общий дедлайн, ключ не меняется между попытками.
  6. Асинхронная часть. At-least-once в очереди и дедупликация у потребителя.

Частые уточняющие вопросы:

«Ключ в Redis или в базе?» В базе, если нужна атомарность с эффектом. Redis — если эффект сам идемпотентен или это кэш ответов для снижения нагрузки.

«Что если клиент повторит через неделю?» Ключ уже удалён по TTL, операция выполнится второй раз. Поэтому TTL согласуют с политикой ретраев клиента и документируют в API.

«Как дедуплицировать нажатие кнопки дважды?» Ключ генерируется при открытии формы оплаты, а не при нажатии. Тогда два клика отправят один и тот же ключ.

Частые ошибки

  • Проверка SELECT перед INSERT без уникального индекса — гонка при параллельных повторах.
  • Новый ключ идемпотентности на каждую попытку — защиты нет.
  • Ключ уникален глобально, а не в пределах клиента — один клиент может получить чужой ответ.
  • Сохранение 5xx как окончательного ответа — клиент больше никогда не сможет выполнить операцию.
  • Нет таймаута у in_progress — после падения сервиса ключ заблокирован навсегда.
  • Ретраи без jitter и без лимита — сервис, который начал подниматься, снова падает.
  • Ретраи на каждом слое — нагрузка растёт мультипликативно.
  • Вера в exactly-once брокера, хотя эффект — внешний вызов.

Потренироваться в спокойной обстановке

Все задачи