Идемпотентность API: Idempotency-Key, ретраи и почему exactly-once — иллюзия
Какие HTTP-методы идемпотентны, как устроить Idempotency-Key и хранение ответов, что делать с параллельными повторами, как ретраить с jitter и дедуплицировать сообщения.
Коротко
- Идемпотентная операция при повторе не меняет состояние сверх первого вызова.
SET status = 'paid'можно повторять,balance = balance + 100— нет. - Повторы неизбежны. Клиент не может отличить «запрос потерялся» от «потерялся ответ», поэтому ретраит. Сервер обязан это пережить.
- Для неидемпотентных операций нужен ключ. Клиент генерирует
Idempotency-Key, сервер атомарно занимает его, выполняет операцию и сохраняет ответ. Повтор получает тот же ответ, а не второй платёж. - Гонку решает уникальный индекс, а не
SELECTпередINSERT. Параллельный повтор с тем же ключом получает 409, повтор с другим телом — 422. - Exactly-once доставки не бывает. Бывает at-least-once плюс дедупликация в чётко очерченной границе.
Почему повторы неизбежны
Клиент отправил POST /payments и через 10 секунд получил таймаут. Что произошло на сервере?
- Запрос не дошёл — платежа нет.
- Запрос дошёл, сервер упал посреди обработки — платёж мог создаться, а мог и нет.
- Платёж создан, но ответ потерялся на обратном пути — деньги списаны.
Со стороны клиента все три случая выглядят одинаково. Если не повторять, теряем платежи в первом случае. Если повторять вслепую — списываем деньги дважды в третьем. Эта же проблема известна как задача двух генералов: по ненадёжному каналу нельзя за конечное число сообщений гарантировать, что обе стороны знают одно и то же.
Повторяют не только клиенты. Ретраят балансировщики, 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 | Хэш тела запроса, чтобы поймать повтор ключа с другими параметрами |
status | in_progress или completed |
response | Код и тело ответа, которые вернём на повтор |
created_at | Для удаления по TTL |
Ответы на разные ситуации:
| Ситуация | Ответ |
|---|---|
| Ключ новый | Выполнить операцию, сохранить ответ |
Ключ есть, completed, тело совпадает | Вернуть сохранённый ответ |
Ключ есть, in_progress | 409 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 без ключа не повторять вовсе — или повторять, только если запрос гарантированно не ушёл (ошибка при установке соединения).
Как часто. Повтор сразу после ошибки — худшая стратегия. Если сервис упал из-за перегрузки, тысяча клиентов мгновенно удвоит нагрузку. Нужна экспоненциальная задержка: с потолком.
Зачем jitter. Если все клиенты получили ошибку в один момент, с чистой экспонентой они вернутся тоже синхронно: через 100 мс, 200 мс, 400 мс — волнами. Случайный разброс размазывает волну по времени. В известном разборе от AWS (Marc Brooker, «Exponential Backoff And Jitter») лучше всего показали себя варианты full jitter и decorrelated jitter. Full jitter самый простой:
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.
Как отвечать на собеседовании
Задачи, где идемпотентность всплывает почти всегда: платёжная система, оформление заказа, отправка уведомлений, бронирование, перевод денег между счетами. План ответа:
- Назвать проблему. Таймаут не значит «не выполнилось», клиент будет повторять.
- Сделать идемпотентным то, что можно. Абсолютные значения, клиентские идентификаторы, переходы состояний.
- Для остального — ключ. Кто генерирует (клиент), где хранится (в той же базе, что и эффект), сколько живёт (по окну ретраев), что возвращаем на повтор.
- Закрыть гонку. Уникальный индекс и статус
in_progress, 409 на параллельный повтор. - Описать клиента. Экспоненциальная задержка с jitter, лимит попыток, общий дедлайн, ключ не меняется между попытками.
- Асинхронная часть. At-least-once в очереди и дедупликация у потребителя.
Частые уточняющие вопросы:
«Ключ в Redis или в базе?» В базе, если нужна атомарность с эффектом. Redis — если эффект сам идемпотентен или это кэш ответов для снижения нагрузки.
«Что если клиент повторит через неделю?» Ключ уже удалён по TTL, операция выполнится второй раз. Поэтому TTL согласуют с политикой ретраев клиента и документируют в API.
«Как дедуплицировать нажатие кнопки дважды?» Ключ генерируется при открытии формы оплаты, а не при нажатии. Тогда два клика отправят один и тот же ключ.
Частые ошибки
- Проверка
SELECTпередINSERTбез уникального индекса — гонка при параллельных повторах. - Новый ключ идемпотентности на каждую попытку — защиты нет.
- Ключ уникален глобально, а не в пределах клиента — один клиент может получить чужой ответ.
- Сохранение 5xx как окончательного ответа — клиент больше никогда не сможет выполнить операцию.
- Нет таймаута у
in_progress— после падения сервиса ключ заблокирован навсегда. - Ретраи без jitter и без лимита — сервис, который начал подниматься, снова падает.
- Ретраи на каждом слое — нагрузка растёт мультипликативно.
- Вера в exactly-once брокера, хотя эффект — внешний вызов.