Пятница, 23:47. PagerDuty: “Платёж AmEx, провайдер вернул 5xx три раза подряд, билеты не зарезервированы.” Открываю логи – действительно 3 ответа провайдера с 5xx, ни одной успешной транзакции по нашей базе. Закрываю как временный сбой на стороне провайдера, пишу короткую сводку в дежурный чат и иду досматривать. Через 40 минут второй алерт – уже от ночной поддержки: клиент прислал скрин выписки, 3 списания подряд за одну бронь. У клиента рейс через 6 часов, ему нужна действующая бронь и подтверждение, что он завтра нормально улетит, а не тикет в поддержку.

Мы делали b2b-платформу для деловых поездок: бронь авиа, отели, трансфер, страховка, в финале – оплата корпоративной картой через платежный шлюз. С этой ночи началась история, которая закончилась переписыванием всего платёжного слоя нашего booking-сервиса. По дороге мы поймали 5 граблей, о которых хочу рассказать.

Idempotency key – это контракт: если запрос дойдёт до сервера дважды, операция выполнится один раз. Звучит как простая техническая договорённость. На проде контракт размазан между мобильным клиентом, нашим сервером, базой и внешним карточным провайдером – и каждый участник понимает слово “дважды” по-своему. Клиент считает дублем повторный запрос с тем же UUID. Провайдер – запрос с теми же реквизитами в течение нескольких секунд. База данных вообще не знает про ключи, только про строки в таблице. Сеть ненадёжна: запрос может дойти до провайдера, обработаться и вернуть таймаут на нашей стороне. Мы не знаем, что деньги уже списаны, и повторяем запрос. Двойное списание гарантировано.

Интуитивное решение – генерировать UUID на клиенте, класть в заголовок Idempotency-Key, сохранять ответ в Redis на 24 часа. Покрывает один сценарий из пяти. Остальные четыре выясняются, когда повторы уже летят и откатиться некуда. Я был уверен, что понимаю, как это работает – пока мы с командой не начали разбирать, что именно произошло с теми тремя транзакциями. Оказалось, у нас не было единого ответа даже на вопрос “кто должен генерировать ключ”.

Грабля 1: ключ создаётся клиентом

Первое, что показал постмортем – ключ генерился не там, где надо. В нашем мобильном PWA на Angular был http-интерсептор, который централизованно навешивал Idempotency-Key на каждый исходящий платёжный запрос. На бумаге решение правильное: централизованно, без копипасты по сервисам, разработчики гарантированно не забудут. На практике в коде это выглядело примерно так:

// http-интерсептор: централизованно навешивает Idempotency-Key
@Injectable()
export class IdempotencyInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    if (!req.url.includes('/payments')) return next.handle(req);
    const idempotencyKey = crypto.randomUUID(); // BAD: новый UUID на каждом исходящем запросе
    return next.handle(req.clone({ setHeaders: { 'Idempotency-Key': idempotencyKey } }));
  }
}

Метод сервиса pay вызывался один раз на клик – тут всё корректно. А вот в подписке поверх его observable стоял rxjs retry({ count: 3, delay: 500 }) – защита от коротких сетевых сбоев, чтобы коротко-живущие 5xx не доходили до пользователя. На каждой retry-попытке observable переподписывался, http-запрос отправлялся заново, проходил через интерсептор – и crypto.randomUUID() в intercept() генерил новый ключ. Из логов мы потом нашли: тот самый инцидент дал 3 разных UUID в течение полутора секунд. С точки зрения пользователя – одно нажатие. С точки зрения нашей дедупликации – три события.

Лекарство простое, как только понимаешь проблему: ключ должен жить столько же, сколько намерение пользователя. Один ключ на одно нажатие “Оплатить”, а не один на одну отправку. Где именно генерировать – в обработчике клика и пробрасывать в pay() параметром, в локальном хранилище клиента, или на сервере как идемпотентный POST /payment-intents – вопрос архитектуры. Принципиально другое: ключ привязан к намерению пользователя, не к технической попытке. На весь период повторов ключ один, даже если попыток было десять.

Грабля 2: TTL ключа короче окна retry

С генерацией ключа на клиенте разобрались, перешли к серверу. Хранилище ключей идемпотентности было в Redis с ttl 24 часа. На дашборде эта цифра выглядела разумно: за сутки любой сценарий повторов покрыт, новые ключи живут долго, старые освобождают память.

Только service worker нашего PWA об этой цифре не знал. У него своя политика повторов: при потере связи запрос складывается в очередь background sync API, и service worker пытается допослать его, пока связь не вернётся – до недели или дольше, в зависимости от настроек браузера. Это норма: пользователь в самолёте, в метро, в зоне без покрытия – клиенту негде хранить, кроме как у себя.

У нас был случай с менеджером в командировке, который потерял связь, вернулся через 4 дня, открыл приложение – и service worker тихонько дослал застрявшие платежи из background-очереди. К этому моменту Redis-хранилище забыло про эти ключи на трое суток раньше. Сервер обработал запросы как новые. Ушли двойные операции, разбираться пришлось через провайдера.

Лекарство: TTL ключа покрывает максимальное окно повторов клиента плюс запас. Если политика повторов клиента 7 дней, ttl 30 дней – не “слишком долго”, а “правильно”. Память дешевле двойных списаний. Если массив ключей растёт неприемлемо – переноси ключи в надёжное хранилище (Postgres вместо Redis, или S3 плюс быстрый кэш), но не урезай ttl до уровня “хватит на один поход в метро”.

Грабля 3: идемпотентность по намерению vs по запросу

После исправления первой грабли у нас была чистая логика: 1 ключ на 1 нажатие “Оплатить”. Думали, что закрыли тему. Через 2 недели прилетел почти идентичный инцидент. Покопались – на этот раз дело было не в клиенте.

Пользователь нажал “Оплатить” на зависшем фронте. Спиннер крутился, ответа от сервера не было – он нажал ещё раз. Через минуту, всё ещё без ответа, нажал ещё раз. Это типичный паттерн: важная транзакция, медленная сеть, пользователь не уверен, что первое нажатие сработало. С точки зрения трёх http-запросов это были три абсолютно легальных вызова с тремя разными intent-id (мы поправили клиента, новый ключ создавался при каждом отдельном клике). По RFC – три разных намерения. По жизни – одно намерение, оформленное трижды.

Простейшая наивная реализация без какой-либо идемпотентности:

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId)
{
    var payment = new Payment(userId, amount, bookingId);
    await db.Payments.AddAsync(payment);
    await db.SaveChangesAsync();

    var providerResult = await paymentProvider.ChargeAsync(payment);
    payment.Apply(providerResult);
    await db.SaveChangesAsync();

    return payment.Result;
}

Три клика – три отдельных платежа в БД, три похода в провайдера, три списания. Чтобы дедуплицировать, инстинктивно хочется навесить дедуп по содержимому запроса – собрать ключ из его полей и сохранить ответ:

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId, DateTime requestTimestamp)
{
    var key = Sha256Hash(userId, amount, bookingId, requestTimestamp); // BAD: timestamp ломает идею
    if (await store.TryGetAsync(key) is { } cached)
        return cached;

    var result = await DoChargeAsync(userId, amount, bookingId);
    await store.SetAsync(key, result, TimeSpan.FromHours(24));
    return result;
}

Беда в том, что три клика приходят с тремя разными requestTimestamp – значит и три разных хеша. Дедуп не сработал. Уберите requestTimestamp – будет другая беда: первый платёж провалился soft decline’ом от банка (перегружен эмитент, временный fraud-флаг, проблема на сети ACQ), пользователь через час пробует ещё раз. Тот же userId, amount, bookingId, currency, тот же хеш. Хранилище вернёт закэшированный fail на новое легитимное намерение – пока ключ не истечёт, оплатить эту бронь нельзя.

Классический компромисс со временем в ключе. С requestTimestamp – защита от серии быстрых нажатий не работает. Без requestTimestamp – серия обработана, но честный повтор через час залипает на старом отказе. И там, и там – часть запросов отрабатывает неверно.

Решение, к которому мы пришли: ключ собирается из бизнес-семантики операции с явным окном намерения, а попадание в хранилище идёт атомарно – чтобы два одновременных повтора не проскочили проверку наперегонки.

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId)
{
    var intentKey = BuildIntentKey(userId, bookingId, amount);

    // Атомарно: резерв ключа со статусом InProgress, либо получение существующего слота
    var slot = await store.AcquireOrGetAsync(intentKey, TimeSpan.FromDays(30));
    if (slot.IsCompleted) return slot.CachedResult;
    if (slot.IsInProgress) throw new ConflictException("operation already in flight");

    // slot.IsReserved – мы первые, кто застолбил этот ключ
    try
    {
        var payment = new Payment(userId, amount, bookingId);
        await db.Payments.AddAsync(payment);
        await db.SaveChangesAsync();

        var providerResult = await paymentProvider.ChargeAsync(payment);
        payment.Apply(providerResult);
        await db.SaveChangesAsync();

        await store.CompleteAsync(intentKey, payment.Result);
        return payment.Result;
    }
    catch
    {
        await store.ReleaseReservationAsync(intentKey);
        throw;
    }
}

static string BuildIntentKey(UserId u, BookingId b, Money m) =>
    $"pay:{u}:{b}:{m.Amount}:{m.Currency}:{Window5Min(DateTime.UtcNow):O}";

Ключ собирается из четырёх параметров и временного окна:

  • userId – привязка к субъекту

  • bookingId + amount + currency – содержание намерения

  • Window5Min(now) – окно, в котором повторное нажатие считается тем же намерением

Атомарный AcquireOrGetAsync под капотом – это SETNX с TTL в Redis или INSERT ... ON CONFLICT DO NOTHING RETURNING в Postgres. Возвращает один из трёх статусов: завершено (отдаём кэш), в работе (второй параллельный запрос получает 409 и инструкцию повторить через секунду), зарезервировано нами (делаем операцию). Обвязка вокруг бизнес-логики выглядит шумно – в проде это уходит в декоратор/middleware над платёжным сервисом, чтобы методы не разрастались boilerplate-ом.

Через 2 часа после первого платежа за ту же бронь – это уже новое намерение, новый ключ, новая операция. В 5-минутном окне после первого – повтор. Окно 5 минут – компромисс: достаточно, чтобы вместить серию повторов от мобильного клиента и серию быстрых нажатий в зависший UI; недостаточно, чтобы случайно склеить настоящие повторы. Реальный пользователь, делающий 3 осмысленных оплаты одной и той же брони в течение 5 минут – редкая патология, заслуживающая ручного разбирательства, а не автодедупа.

Урок этой грабли – пожалуй, самый важный в посте. Идемпотентность – свойство бизнес-операции, а не http-запроса. RFC задаёт механику заголовка Idempotency-Key, а вот семантику – что считать одной операцией, а что разными – задаёт ваша бизнес-логика. Дедуп на трансляции http-заголовка работает в одном сценарии из пяти; дедуп на бизнес-намерении накрывает повторы, серии нажатий, переотправку из сети и пользовательскую неуверенность одним способом.

Грабля 4: внешний неидемпотентный вызов

Поправили клиента, переделали хранилище на intent-key с атомарным резервом. Внутри нашей системы идемпотентность теперь устроена нормально. И снова прилетел инцидент с двойным списанием – уже третий, к этому моменту мы перестали удивляться.

На этот раз сценарий сложнее. Пользователь нажал 1 раз. Наш intent-key один. Хранилище сказало “такого ещё не было, резервирую за тобой”. Вызвали paymentProvider.ChargeAsync(payment). Платёжный провайдер провёл операцию, списал деньги, отправил ответ. По дороге балансировщик на стороне провайдера отвалился, и наш сервер получил таймаут. Мы пометили операцию как требующую повтора, фоновый процесс через минуту повторил вызов. Провайдер обработал второй вызов как новый – он не понимал наш intent-key. Списались деньги второй раз.

Граница нашей идемпотентности заканчивалась на границе системы. Дальше за неё отвечает провайдер – и многие провайдеры этого не делают.

Лекарство в общем виде – двухфазная фиксация: сначала зарезервировать на стороне провайдера reference, потом по нему провести commit. Reference создаётся идемпотентно (провайдер гарантирует), commit идемпотентен по тому же reference (провайдер гарантирует), а связку reference↔intentKey хранит наша сторона:

// двухфазная фиксация на провайдере, не понимающем наш intentKey
var providerRef = await provider.AcquireReferenceAsync(intentKey);
await store.SaveProviderRefAsync(intentKey, providerRef);
var charge = await provider.CommitAsync(providerRef);
// AcquireReferenceAsync идемпотентен; CommitAsync идемпотентен по ref

Тут, кстати, спрятан ещё один тонкий момент: между Acquire и Save сервер может упасть. Reference у провайдера уже есть, мы про него ещё не знаем – orphaned reference. Закрывается reconciliation-процессом, который периодически сверяет наши записи с провайдерскими и подтягивает потерявшиеся связки. Это уже соседняя тема, в этом посте просто запишем, что такая проблема в принципе существует, out of scope.

Если провайдер поддерживает собственный Idempotency-Key (Stripe, Adyen, CloudPayments – да; некоторые внутренние банковские шлюзы – нет), вариант проще: транслируем наш intentKey в их заголовок, ответственность лежит на стороне провайдера.

Если провайдер не поддерживает ничего из этого – оборачиваем его в свой адаптер с собственным reference-store и реализуем двухфазную модель на свой страх и риск. Это работает, но добавляет новые точки отказа.

Урок: идемпотентность цепочки – это идемпотентность самого слабого звена. Граница системы – граница ответственности. На каждом стыке между сервисами проверяем, что обе стороны понимают одно и то же слово “повтор”.

Грабля 5: response replay

Последняя грабля – самая невидимая. Допустим, всё предыдущее сделано правильно: ключ привязан к намерению, хранилище с длинным ttl, провайдер обёрнут в двухфазку. Через неделю после успешной операции пользователь – или service worker из background-очереди, или браузер, восстановивший вкладку из фона – повторяет запрос с тем же intent-key. Что вернуть?

Очевидный ответ – закэшированный успех. Храним результат первой операции, отдаём его без повторного похода в шлюз. Идемпотентно, быстро, копий в системе нет.

Проблема в том, что между первой операцией и повтором мир мог измениться. Реальный кейс: клиент оплатил бронь авиа, через два дня позвонил в поддержку и отменил – деньги вернули, бронь сняли. Ещё через неделю в его браузере отработал отложенный sync – service worker допослал тот же запрос из очереди (вкладка с зависшей оплатой долго оставалась открытой в фоне). Запрос приходит на сервер с тем же intent-key, хранилище отдаёт закэшированный “success”. Клиентское приложение показывает в истории: “оплата активна, бронь подтверждена”. В нашей же базе этой брони уже нет. Час разборок с поддержкой, пока юзер не понял, что видит призрак из прошлого. Технически идемпотентность не нарушена – вернули тот же ответ, что и в первый раз. но семантически – соврали о текущем состоянии.

Решение в общем виде: явно отделять “это replay” от “это первая попытка”. Вот 2 варианта, как это сделать:

Можно отдавать оригинальный ответ дословно – тогда клиент должен понимать, что ответ может быть устаревшим, и проверять состояние перед действием. Подходит, когда состояние не успевает уйти в другую сторону за окно retry.

Можно отдавать 200 OK со ссылкой на актуальный статус операции: /payments/{id}. Клиент идёт по ссылке, видит текущее состояние, принимает решение. Чуть дороже по запросам, семантически честнее: мы говорим “операция была, иди посмотри что с ней сейчас”.

В итоге response replay – часть контракта идемпотентности, не побочка. Если о нём не думать заранее, в проде он проявится тихо – через UX-баги, не через 500-е ошибки.

Точки отказа на одной картинке

Точки отказа идемпотентности – где может сломаться каждая из 5 граблей по ходу потока.
Точки отказа идемпотентности – где может сломаться каждая из 5 граблей по ходу потока.
sequenceDiagram
    autonumber
    participant C as Клиент (mobile)
    participant S as Сервер
    participant P as Карточный провайдер

    Note over C: Грабля 1 – ключ внутри метода<br/>→ 3 повтора, 3 разных ключа
    C->>S: POST /payments {amount, idempotencyKey}
    Note over S: Грабля 3 – ключ из тела, не из намерения<br/>3 разных http-key → 3 операции
    S->>S: store.AcquireOrGet(key)
    Note over S: Грабля 2 – TTL 24h, retry клиента до недели<br/>через 3 дня ключ истёк
    S->>P: charge(payment)
    Note over P: Грабля 4 – провайдер не понимает наш ключ<br/>ответ потерян → повтор → двойное списание
    P-->>S: result
    S->>S: store.Complete(key, result)
    S-->>C: 200 {result}
    Note over S,C: Грабля 5 – на повторе вернуть закэш. успех<br/>или статус-ссылку с актуальным состоянием?

Чем кончилось

Платёжный слой в итоге переписали довольно радикально. UUID на клиенте выкинули, intent-key собирали уже на сервере. Связали наш intent-key со ссылкой на операцию у провайдера через двухфазную фиксацию. Закэшированный успех на повтор заменили статус-ссылкой. TTL подняли до 30 дней, хранилище перенесли в надёжную БД. Пара эпизодов после релиза всё-таки была – на тестовом окружении забыли поправить флаг отказного режима, потом нашли. Но инциденты такого класса больше не повторялись.

Если перечитать все 5 граблей подряд, видно одно: это 5 частных случаев одной ошибки. Думали про дедупликацию http-запроса, а надо было – про идентификацию намерения пользователя. На уровне транспорта ловятся технические повторы (сети, флапы прокси, перезаливка из локальной очереди клиента) – а человеческое “я нажал три раза, потому что зависло” транспорт не видит, это уже уровень бизнес-операции. Pat Helland в “Idempotence is Not a Medical Condition” формулирует это как разделение слоёв: транспорт не гарантирует единственность доставки, потому что сеть может дублировать пакет в любой момент; приложение может гарантировать единственность эффекта – именно там и должна жить идемпотентность. Кто прибил её к http-заголовку, защищается от не той категории ошибок.


Что почитать

  • Pat Helland – “Idempotence is Not a Medical Condition” (ACM Queue, 2012). Фундаментальный аргумент против привязки идемпотентности к транспортному уровню. Короткая, читается за вечер.

  • Brandur Leach – “Designing robust and predictable APIs with idempotency” (Stripe blog, 2017). Каноничная инженерная реализация в продакшене: какие гарантии, как организован store, как обрабатывать concurrent requests с одним ключом.

  • IETF draft “The Idempotency http Header Field” (Yasskin, Nottingham). Куда движется стандарт. Знать полезно, верить как контракту – рано: статус draft.

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


  1. Dmitri-D
    27.05.2026 02:18

    Браво. Но это вы еще не добрались до eventual consistency и region availability. Традиционные базы не масштабруются - если бизнес растет быстро, все решения должны быть масштабируемые, включая базу и Postgres - это вариант только до первого серьезного пика. Для "настоящих сварщиков" есть DDB или ее opensource вариант Cassandra - вот они масштабируются. но они eventually consistent и их использовать надо очень аккуратно. Я не про вашу компетенцию - вы точно сможете. Я лишь про то, что грабли там тоже припасены и не одна.
    А если вы доберетесь до объемов Stripe, вам еше и сеть начнет сильно мешать - с ее задержками, потерями пакетов и доступностью. И чем больше вы будете распределять нагрузки, тем сильнее сеть будет мешать.