Привет, Хабр!

Мобильное приложение отправляет POST /payments, ответ не приходит за 10 секунд, и клиент повторяет запрос.

Чтобы повтор не списал деньги второй раз, в API добавляют заголовок Idempotency-Key, как у Stripe: клиент присылает случайную строку, сервер запоминает результат первого запроса с этим ключом и на повторы отдаёт его же.

Пишут это обычно в три шага:

  1. Поискать ключ в таблице.

  2. Выполнить операцию.

  3. Сохранить ответ.

В тестах всё зелёное. Там повтор приходит, когда первый запрос уже закончился.

А теперь вопрос: когда уходит ретрай по таймауту?

Правильно, пока первый запрос, скорее всего, ещё выполняется. Клиент сдался через 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

Комментарии (0)