Привет, Хабр!
Мобильное приложение отправляет POST /payments, ответ не приходит за 10 секунд, и клиент повторяет запрос.
Чтобы повтор не списал деньги второй раз, в API добавляют заголовок Idempotency-Key, как у Stripe: клиент присылает случайную строку, сервер запоминает результат первого запроса с этим ключом и на повторы отдаёт его же.
Пишут это обычно в три шага:
Поискать ключ в таблице.
Выполнить операцию.
Сохранить ответ.
В тестах всё зелёное. Там повтор приходит, когда первый запрос уже закончился.
А теперь вопрос: когда уходит ретрай по таймауту?
Правильно, пока первый запрос, скорее всего, ещё выполняется. Клиент сдался через 10 секунд, сервер ответил бы через 12, и все эти 2 секунды по системе ходят два запроса с одним ключом. 4
Ни один из них ещё ничего не сохранил.
Оба находят таблицу пустой и оба списывают деньги.
Давайте соберём обработчик, который такое переживает, и по дороге посмотрим, где его обычно недоделывают.
Проверить, потом записать
Начнём с версии, которую пишут первой. Внешний провайдер здесь заменён функцией charge, которая думает 300 мс и добавляет строку в таблицу списаний:
def pay_naive(client, key, body): with psycopg.connect(DSN, autocommit=True) as db: row = db.execute("select status_code, response from idempotency " "where client_id=%s and key=%s", (client, key)).fetchone() if row: return row charge_id = charge(db, body["amount"]) resp = {"charge_id": charge_id} db.execute("insert into idempotency (client_id, key, fingerprint, status, " "status_code, response) values (%s, %s, '', 'done', 201, %s)", (client, key, json.dumps(resp))) return 201, resp
Выглядит даже разумно. Запускаем два потока одновременно с одинаковым ключом:
(201, {'charge_id': 1}) UniqueViolation: duplicate key value violates unique constraint "idempotency_pkey" списаний: 2
Первый клиент получил ответ, второй получил 500, а у провайдера два списания.
Первичный ключ таблицы сработал, только поздно. Он защищает таблицу, а деньги к этому моменту уже ушли. Между select и insert лежит вызов провайдера, и всё, что попало в это окно, проходит мимо проверки.
Сократить окно не выйдет. Оно длится столько же, сколько сама операция. Значит, меняем порядок шагов.
Сначала занять ключ
Ключ записывается в таблицу до начала работы, со статусом «в процессе». Кто из конкурентов победил, решает уникальный индекс, и решает атомарно, без отдельной блокировки:
create table idempotency ( client_id text not null, key text not null, fingerprint text not null, status text not null default 'in_progress', locked_until timestamptz not null default now() + interval '30 seconds', status_code int, response jsonb, created_at timestamptz not null default now(), primary key (client_id, key) );
Обработчик пытается вставить строку и смотрит, получилось ли. Не получилось — значит, ключ уже кто‑то занял, и дальше всё решает содержимое строки:
def pay(client, key, body): fp = fingerprint(body) with psycopg.connect(DSN, autocommit=True) as db: if not reserve(db, client, key, fp): st, code, resp, old_fp = db.execute( "select status, status_code, response, fingerprint from idempotency " "where client_id=%s and key=%s", (client, key)).fetchone() if old_fp != fp: return 422, {"title": "Idempotency-Key is already used"} if st == 'in_progress': return 409, {"title": "A request is outstanding for this Idempotency-Key"} return code, resp charge_id = charge(db, f"{client}:{key}", body["amount"]) resp = {"charge_id": charge_id} db.execute("update idempotency set status = 'done', status_code = 201, " "response = %s where client_id = %s and key = %s", (json.dumps(resp), client, key)) return 201, resp
Коды ответов я взял из черновика IETF про заголовок Idempotency-Key. RFC из него пока не получился: последняя редакция 07 датирована октябрём 2025 года и числится истёкшей.
Сам заголовок тем не менее давно стандарт де‑факто, черновик перечисляет среди реализаций Stripe, Adyen, Worldpay и Яндекс.
422 — повтор с другим телом.
409 — повтор во время выполнения первого запроса.
Сохранённый ответ первого запроса — повтор после завершения.
Те же два одновременных потока и несколько повторов следом:
(409, {'title': 'A request is outstanding for this Idempotency-Key'}) (201, {'charge_id': 3}) повтор после: (201, {'charge_id': 3}) другая сумма: (422, {'title': 'Idempotency-Key is already used'}) тот же ключ у другого клиента: (201, {'charge_id': 4}) списаний: 2
Двух списаний для shop-1 больше нет. Одно его, второе принадлежит shop-2, и к этой строке мы ещё вернёмся.
Логичный вопрос:
А зачем 409, почему бы просто не подождать первый запрос и не отдать второму его ответ?
Для коротких операций в одной базе так и делают. Для долгих это значит держать открытым соединение клиента, который уже один раз отвалился по таймауту и вполне может отвалиться снова.
Клиенту с 409 проще подождать и повторить, тем более что Stripe отдельно оговаривает в документации: такие запросы можно спокойно ретраить, результат для них не сохраняется.
Что входит в отпечаток
422 нужен на случай, когда клиент по ошибке отправил один ключ с двумя разными операциями. Без проверки второй платёж на 500 рублей получил бы ответ от первого, на 100, и клиент решил бы, что всё прошло.
Функция fingerprint считает хэш от тела запроса:
def fingerprint(body): canon = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False) return hashlib.sha256(canon.encode()).hexdigest()
Почему не хэш от сырых байт? Так быстрее, и разбирать ничего не надо. Но тогда повтор, сериализованный другой библиотекой или с другим порядком полей, получит 422, хотя запрос тот же. Канонизация с сортировкой ключей эту разницу убирает.
И совать в отпечаток всё тело подряд тоже не стоит. Если клиент кладёт туда метку времени отправки или номер попытки, каждый ретрай окажется «другим» запросом, и защита превратится в стабильный 422 на любой повтор.
В отпечаток идёт то, что определяет операцию:
сумма;
валюта;
получатель;
назначение.
Помните строку «тот же ключ у другого клиента»? Первичный ключ таблицы — пара (client_id, key), а не один key, и это не случайность.
Черновик оставляет формат ключа на усмотрение владельца ресурса и только рекомендует UUID, а клиенты, которым никто не запрещал, присылают всё подряд, от order-1 до номера заказа в собственной учётной системе.
Сделайте уникальность по одному ключу, и два магазина, у которых совпали номера заказов, начнут делить один кэш ответов. Второй магазин отправляет order-1 и получает чужой charge_id.
В ответах посерьёзнее там могут оказаться чужие реквизиты, и это уже разговор с безопасниками.
Процесс упал посреди операции
Теперь сценарий похуже. Обработчик занял ключ, провайдер списал деньги, и тут процесс убили: деплой, OOM, выдернутая нода.
Строка в таблице навсегда осталась in_progress. Без дополнительной логики клиент будет получать 409 на каждый повтор, пока кто‑нибудь не вычистит таблицу руками.
Для этого в строке лежит locked_until. Это срок аренды. Повтор, пришедший после её истечения, может забрать ключ себе и выполнить операцию заново:
insert into idempotency (client_id, key, fingerprint) values (%s, %s, %s) on conflict (client_id, key) do update set locked_until = now() + interval '30 seconds' where idempotency.status = 'in_progress' and idempotency.locked_until < now() and idempotency.fingerprint = excluded.fingerprint returning true
Это и есть тело функции reserve. Условие в where срабатывает только для зависших строк с тем же отпечатком, а returning вернёт строку, только если вставка или перехват действительно случились.
А как же деньги?
«Выполнить заново» значит вызвать провайдера второй раз, и вся конструкция держится на том, что провайдер этот вызов узнает. Поэтому обработчик передаёт ему собственный ключ, f"{client}:{key}", производный от клиентского.
Сравним с вариантом, где провайдеру на каждую попытку уходит новый UUID:
# провайдеру передаётся производный ключ процесс упал после списания ретрай сразу: (409, {'title': 'A request is outstanding for this Idempotency-Key'}) ретрай после аренды: (201, {'charge_id': 5}) списаний: 1 # провайдеру каждый раз новый UUID процесс упал после списания ретрай после аренды: (201, {'charge_id': 8}) списаний: 2
Таблица ключей защищает только то, что происходит внутри сервиса.
Если операция выходит наружу, ключ должен ехать вместе с ней, иначе любое падение между вызовом провайдера и записью результата заканчивается повторным списанием.
Провайдер ключей не принимает? Тогда перед повтором остаётся спрашивать у него по своему идентификатору заказа, прошла ли операция.
Срок аренды берём длиннее самого долгого запроса, со всеми таймаутами до провайдера. Аренда на 30 секунд при таймауте провайдера в 60 позволяет повтору перехватить ключ, пока первый обработчик ещё жив и ждёт ответа, и тогда два вызова уйдут параллельно.
Спасёт тот же производный ключ, только полагаться на это как на основную защиту я бы не стал.
Когда всё в одной базе
Бывает, что «операция» — это просто запись в ту же базу: создать заказ, начислить бонусы. Тогда ключ можно занимать в той же транзакции, что и саму работу, и конкурентов PostgreSQL разведёт сам.
Вставка дубликата в уникальный индекс, пока первая транзакция не завершилась, не падает и не проходит. Она ждёт:
второй insert ждал 1.8 с, вставил: None
Первая транзакция заняла ключ и 2 секунды делала работу. Вторая с on conflict do nothing простояла до коммита первой, увидела готовую строку и ничего не вставила.
Откатись первая — вторая сделала бы работу сама.
Статус in_progress, аренда и 409 в такой схеме не нужны вовсе, конкурент просто ждёт и получает готовый ответ.
Наружу это не переносится. Транзакция, открытая на время запроса к провайдеру, держит соединение из пула и блокировки, и как только провайдер начинает тормозить, пул кончается раньше, чем таймауты.
Для внешних операций остаётся схема с арендой.
Что хранить и сколько
Сохраняем код и тело ответа, причём и ошибочного тоже.
Stripe запоминает результат первого запроса с ключом независимо от того, успешный он или нет, включая 500. Если операция начала выполняться, повтор не должен её перезапускать, даже когда в первый раз она закончилась ошибкой.
Отказ по карте — окончательный ответ. Его и отдаём на повторы.
С ошибками, случившимися до начала работы, всё наоборот. Невалидное тело запроса проверяется до reserve, ключ при таком отказе не занимается: клиент исправит запрос и пришлёт его с тем же ключом.
Stripe поступает так же и сохраняет результат только после того, как началось выполнение эндпоинта.
Вечно хранить ключи незачем. У Stripe ключ может быть удалён, когда ему исполнилось 24 часа, и повтор после этого создаст новую операцию.
Чистится всё обычным фоновым delete:
delete from idempotency where status = 'done' and created_at < now() - interval '24 hours';
Срок хранения лучше опубликовать в документации API, черновик IETF это и рекомендует.
Клиенту полезно знать, что ретрай через двое суток уже ничем не защищён.
Ключ работает там, куда его довезли
Если унести отсюда одну мысль, пусть будет такая:
Идемпотентность держится на всей цепочке сразу.
Ключ надо протянуть от кнопки в приложении, где он создаётся, через обработчик, где его занимают до начала работы, до провайдера, которому уходит производный ключ.
Разрыв в любом месте возвращает двойное списание, и вылезает оно в самый неудобный момент — когда повтор пришёл, пока первый запрос ещё жив.
Если начинать с нуля, самое дешёвое — поменять порядок: занимать ключ до работы, а не записывать после. Это закрывает гонку одновременных повторов.
Следом по важности идут производный ключ для провайдера и аренда, чтобы падение процесса не оставляло ни зависших ключей, ни повторных списаний.
А городить ради того же эффекта распределённые блокировки на Redis не стоит: уникальный индекс в базе, где и так лежит таблица ключей, делает ту же работу без второй системы, которую пришлось бы держать согласованной с первой.

Два запроса с одним Idempotency-Key могут одновременно пройти проверку и запустить списание. Чтобы находить такие гонки до продакшена, нужно уметь воспроизводить конкурентные запросы под нагрузкой и проектировать взаимодействие сервисов с учётом таймаутов и отказов. Это помогает заранее обнаруживать уязвимые места и быть увереннее, что повторный запрос или сбой провайдера не обернётся потерей денег.
Углубиться в эти темы можно на бесплатных открытых уроках:
5 ноября, 20:00. «Стенды для нагрузочного тестирования». Записаться.
9 ноября, 20:00. «Монолит или микросервисы? Руководство для архитекторов, которые ценят свои нервы». Записаться.
Больше практических занятий по бэкенд‑разработке, тестированию и архитектуре систем — в календаре вебинаров OTUS