Два неверных вывода из одного сбоя
Клиент нажимает «Оплатить». Приложение шлёт POST /payments. Через пять секунд HTTP‑клиент падает по таймауту. Ретрай‑политика ждёт двести миллисекунд и отправляет запрос снова. Через минуту в выписке — два списания.
Дальше на разборе обычно звучат две реакции, и обе неверны.
Реакция первая: выключить retry на POST. Выключили. Теперь есть платежи, про которые неизвестно, прошли они или нет. Поддержка разбирает их руками. Ущерб не исчез, он переехал в другой отдел.
Реакция вторая: добавить заголовок Idempotency-Key. Добавили, дедупликацию сделали в middleware поверх Redis. Через полгода двойное списание повторяется. Потому что Redis пережил failover и потерял ключи. Или потому что повтор пришёл через сутки из файла клиринга, где никакого HTTP‑заголовка нет.
Обе реакции исходят из одного допущения: идемпотентность — свойство отдельного вызова. Это не так. Идемпотентность — свойство контракта между двумя сторонами, и этот контракт должен дойти без потерь через весь конвейер: от кнопки в приложении до проводки в главной книге и дальше, до файла, который уходит в платёжную схему.
Отсюда практическое следствие. Retry — это тактика, которая работает только там, где контракт уже есть. Без контракта retry не делает систему устойчивее: он делает её быстрее ломающейся. А ещё — и это стоит проговорить отдельно — цена ошибки здесь несимметрична. Потерянный платёж клиент повторит, оператор найдёт, деньги никуда не денутся. Задвоенный платёж означает, что списали чужие деньги без основания: возвраты, регуляторные сроки расследования, сниженная достоверность выписки. И находит задвоение обычно не ваша система, а клиент.
Ссылка на первоисточник разбора: статья об идемпотентности в платёжном конвейере.
Почему потерянный платёж не равен задвоенному
Потерянный платёж и задвоенный платёж — не два одинаково плохих исхода. В обычном сервисе они сопоставимы: не пришло уведомление — клиент нажмёт ещё раз. В платежах асимметрия жёсткая. Если запрос пропал, деньги никуда не делись, клиент повторит операцию, оператор сверит выписку и закроет вопрос. Если операция применилась дважды, вы списали чужие средства без основания. Дальше — возвраты, расследование в регуляторные сроки, потеря доверия к выписке. И находит задвоение обычно сам клиент, а не ваша система.
Отсюда правило поведения конвейера при неопределённости: не повторять. Таймаут или обрыв связи не означают ошибку. Это отсутствие информации, и трактовать его как «не прошло, можно ещё раз» нельзя. Повтор в такой ситуации — это ставка, а не восстановление.
Как именно источник неопределённости выглядит на практике, видно из перечня кодов. Безопасно повторять запрос без ключа можно ровно в одном случае — когда соединение не установилось вообще: connection refused, ошибка DNS или TLS. Тогда известно, что байты не покинули хост. Всё остальное — уже ставка:
| Что случилось | Что известно | Повторять без ключа |
|---|---|---|
connection refused, ошибка DNS/TLS | Запрос не ушёл | Можно |
| Таймаут ожидания ответа | Запрос ушёл, исход неизвестен | Нельзя |
connection reset после отправки | Запрос мог дойти | Нельзя |
500 | Ошибка могла случиться после эффекта | Нельзя |
502, 504 | Прокси не дождался, бэкенд мог отработать | Нельзя |
429 | Отклонён до обработки | Можно, с паузой |
400, 422, 401, 403 | Запрос некорректен | Бессмысленно |
Практический вывод для платёжного эндпоинта: дефолтная политика «повторить при 5xx и сетевой ошибке» здесь не подходит. Она даёт задвоение не всегда, а только когда запрос успел дойти до обработки. Значит, редко и нерегулярно — и именно поэтому такая политика живёт в коде месяцами: воспроизвести её в тесте нечем.
Когда задвоение всё-таки произошло, порядок действий стоит зафиксировать заранее: остановить источник, определить полный периметр, а не «сколько жалоб поступило», компенсировать новыми записями с ключами и сохранить след разбора. Жалуются единицы процентов затронутых, поэтому счёт по обращениям занизит масштаб. А дедупликация обязана оставлять запись: если система молча отбросила повтор, на вопрос «мы отправили два запроса, списание одно — почему» ответить будет нечем.
Что именно делает платёж идемпотентным
Возьмите два выражения: SET balance = 1000 и balance = balance - 100. Первое можно выполнить сколько угодно раз — результат один и тот же. Второе при повторе спишет деньги дважды. Формально идемпотентность означает f(f(x)) = f(x): двойное применение равно одинарному. Команда, задающая состояние, этому условию удовлетворяет. Команда, задающая изменение, — нет.
Деньги устроены как изменения. У перевода есть сумма, но нет смысла «установить баланс в X»: между чтением и записью успевают пройти ещё несколько операций, и ваше X уже устарело. Отсюда вывод, который многим не нравится: платёжные операции не бывают идемпотентными сами по себе. Их приходится делать такими искусственно — через идентификатор намерения. Другого способа нет.
Ключ уникален по намерению, а не по содержимому. Два одинаковых платежа одному получателю — это два разных намерения, и оба должны пройти. Хэш тела запроса здесь не работает: тело почти никогда не стабильно (timestamp, nonce, порядок полей от сериализатора), а если стабильно — хэш склеит два легитимных одинаковых перевода, и второй молча пропадёт.
Вывести ключ можно тремя способами. Клиентский UUID в заголовке Idempotency-Key — так делают публичные API крупных провайдеров. Естественный ключ — то, что в предметной области уже уникально: номер платёжного поручения, EndToEndId из ISO 20022, пара «файл плюс номер записи» для клиринга. Но для конвейера правильный ответ — гибрид: клиентский ключ принимается на внешней границе, а дальше каждый шаг выводит свой ключ из исходного намерения по фиксированному правилу.
payment_intent_id = UUID от клиента или выданный на границе
ledger_entry_key = payment_intent_id + ":debit"
scheme_message_key = payment_intent_id + ":auth:1"Вывод по правилу, а не случайные ключи на каждом шаге, даёт важное свойство: при реплее ключи воспроизводятся сами. Их не надо хранить и передавать отдельным полем. Реплей журнала за прошлые сутки не создаст ни одной новой проводки — именно потому, что каждый шаг выводит свой ключ из того же намерения.
Дальше ключ должен дойти до конца конвейера. Область его действия — тройка (tenant_id, endpoint, key), а не глобальная уникальность: иначе один клиент случайно займёт ключ другого и получит чужой сохранённый ответ. Один и тот же ключ в POST /payments и POST /refunds означает разные намерения. Если ключ пришёл с другим телом — сервер возвращает 422 с кодом idempotency_key_reuse, без нового эффекта и без старого ответа: отдать старый ответ здесь опаснее ошибки, клиент решит, что прошёл его новый запрос.
Формальная идемпотентность остаётся свойством одной функции. Идемпотентность платежа — свойство контракта, и держится она на одном ключе, выведенном из намерения и проведённом без потерь через все уровни.
Почему дедупликация в middleware обречена
Проверка ключа и запись эффекта — две операции. Если они идут в разных транзакциях, между ними всегда остаётся окно, и исход зависит от того, в какую сторону процесс упадёт.
Разберём оба направления. Отметка о ключе закоммичена, а проводки нет — при повторе клиент получит ответ «успех» по платежу, которого не было. Проводка закоммичена, а отметка о ключе нет — при повторе операция выполнится второй раз. Оба окна узкие, оба срабатывают не всегда, а потому годами остаются незамеченными.
Рабочая конструкция выглядит так: захват ключа, проводка и отметка о завершении — в одной транзакции:
await using var tx = await conn.BeginTransactionAsync(ct);
var claim = await TryClaimAsync(conn, tx, scope, key, digest, ct);
if (claim is not ClaimResult.Acquired)
return await HandleNotAcquiredAsync(claim, tx, ct);
var effect = await ledger.PostAsync(conn, tx, request, ct);
var response = Responses.Created(effect);
await CompleteAsync(conn, tx, scope, key, response, ct);
await tx.CommitAsync(ct);Здесь важна вторая строка. Ветка «ключ не наш» не бросает исключение и не запускает операцию заново — она возвращает результат разбора: сохранённый ответ для завершённого повтора, 409 для параллельной попытки, 422 при несовпадении отпечатка.
Из этого следует вывод, который не всем нравится: таблица ключей живёт в той же базе, что и проводки. Отдельный «сервис идемпотентности» в этой схеме не работает — общий коммит между двумя разными хранилищами невозможен.
Redis здесь тоже не подходит как источник истины. Репликация асинхронная, при failover подтверждённая запись может исчезнуть, общей транзакции с проводкой не бывает. Как фильтр перед базой он полезен: снимает нагрузку при массовых повторах. Арбитром остаётся база, а последним рубежом — уникальный индекс в самой таблице проводок.
Про этот индекс стоит помнить одну вещь. Его срабатывание означает не «защита отработала», а «выше по стеку есть баг». Следующий дубликат может прийти там, где индекса нет, поэтому метрику по срабатываниям считают отдельно, и её целевое значение — ноль.
Ключ, который переживёт всё
Ключ уникален по намерению, а не по содержимому. Два одинаковых платежа одному получателю — это два разных намерения, и оба должны пройти. Если строить ключ из суммы, счёта и получателя, вы склеите легитимные регулярные платежи: зарплату, подписку, ежемесячный взнос. Второй перевод молча пропадёт.
Отсюда ещё одно требование: ключ ничего не значит для получателя. Как только в него зашили осмысленные поля, эти поля рано или поздно начнут парсить снаружи. Разбирать чужой идентификатор — плохая идея, поэтому не давайте для этого повода.
Кто и когда генерирует. Вариантов три. Клиентский ключ — UUID в заголовке Idempotency-Key, так делают публичные API крупных провайдеров, и на этот заголовок есть черновик стандарта IETF. Схема универсальная, но целиком зависит от дисциплины клиента: он может не прислать ключ или сгенерировать новый на каждый повтор. Естественный ключ берут из предметной области — то, что уже уникально само по себе: номер платёжного поручения, EndToEndId из ISO 20022, пара «файл плюс номер записи» для клиринга. Это работает даже там, где клиент про идемпотентность не задумывался. И третий, гибридный вариант: клиентский ключ принимается на внешней границе, а дальше каждый шаг выводит свой ключ из исходного намерения по фиксированному правилу.
payment_intent_id = UUID от клиента или выданный на границе
ledger_entry_key = payment_intent_id + ":debit"
scheme_message_key = payment_intent_id + ":auth:1"
notification_key = payment_intent_id + ":webhook:settled"Вывод по правилу, а не случайные ключи на каждом шаге, даёт важное свойство. При реплее любой стадии ключи воспроизводятся сами, их не нужно хранить и передавать отдельным полем. Реплей журнала за прошлые сутки не создаст ни одной новой проводки.
Три способа выбрать ключ неправильно. Первый — хэш тела запроса. Тело почти никогда не стабильно: там есть timestamp, nonce, а порядок полей зависит от сериализатора. Один изменившийся байт — и повтор считается новым намерением. Второй — генерация ключа внутри цикла повторов:
for (var attempt = 0; attempt < 3; attempt++)
{
var key = Guid.NewGuid().ToString(); // новый ключ на каждой попытке
try { return await client.PayAsync(request, key, ct); }
catch (TimeoutException) { }
}Такой ключ не защищает вообще: каждая попытка выглядит как новое намерение. Ключ должен жить снаружи цикла, а лучше — снаружи процесса, сохранённый вместе с намерением до первой попытки. Если он в памяти процесса, после перезапуска старое намерение получит новый ключ. Третий способ — ключ из времени вроде {accountId}-{yyyyMMddHHmmss}: он перестаёт работать, как только повтор попадает в следующую секунду.
Область действия. Ключ уникален в пределах тройки (tenant_id, endpoint, key), а не глобально. Если он уникален на весь сервис, один клиент может случайно занять ключ, уже использованный другим, и получить чужой сохранённый ответ — утечка данных о чужой транзакции через механизм, который задумывался как средство надёжности. Эндпоинт входит в тройку по той же причине: один и тот же ключ в POST /payments и POST /refunds означает разные намерения.
Отдельный случай — тот же ключ с другим телом. Клиент переиспользовал ключ по ошибке, изменил сумму и повторил, либо это подбор. Ни в одном варианте нельзя молча применять запрос. Рядом с ключом хранится отпечаток запроса, и при несовпадении сервер возвращает 422 с кодом idempotency_key_reuse — без нового эффекта и без старого ответа. Старый ответ здесь опаснее ошибки: клиент решит, что прошёл его новый запрос с новой суммой.
Естественный ключ против TTL. Хранить ключи вечно нельзя, значит есть окно дедупликации. Окно покрывает самый длинный канал повтора, который вы допускаете: HTTP-ретрай живёт секунды, повтор из брокера — часы, повторный вебхук вендора — до суток, перезалитый файл клиринга — дни, ручной повтор оператора — недели. На границе окна повтор превращается в новую операцию и второе списание. Поэтому уровней защиты два. Ключ идемпотентности защищает внутри окна и умеет вернуть сохранённый ответ. Естественный ключ на бизнес-уровне — тот самый уникальный индекс в таблице проводок — защищает без всякого TTL. Первый обеспечивает правильный ответ, второй правильный эффект. Второй дешевле и живёт дольше. Опора на один только TTL означает, что ваша защита истекает по расписанию, а не по факту.
Как проектировать конвейер с учётом повторов
Большинство кода знает два исхода вызова: успех и ошибка. В платёжном конвейере их три. Третий — неопределённость: таймаут ожидания ответа, Connection reset после отправки, 502 или 504 от прокси, падение собственного процесса между отправкой и получением. Это не ошибка. Это отсутствие информации об исходе, и обрабатывать его надо иначе.
Из третьего состояния напрямую выводится требование к API. Для каждой операции, меняющей состояние, нужен метод чтения: GET /payments?client_reference=.... Без него вызывающая сторона при таймауте физически не может узнать, прошёл платёж или нет, — остаётся угадывать. Проектируйте метод чтения одновременно с методом записи, а не «когда понадобится»: понадобится он в первый же серьёзный инцидент.
Шаг должен быть идемпотентен по построению
Платёж — это не один эффект, а цепочка: резерв на счёте, проводка, сообщение в схему, подтверждение, уведомление. Сбой возможен между любыми двумя звеньями. Ключ на входе гарантирует, что цепочка не запустится дважды с нуля, но не гарантирует, что она завершится.
Помогает конечный автомат с сохранением состояния после каждого шага — при одном условии: каждый шаг сам идемпотентен и имеет свой ключ, выведенный из исходного намерения по фиксированному правилу:
payment_intent_id = UUID от клиента или выданный на границе
ledger_entry_key = payment_intent_id + ":debit"
scheme_message_key = payment_intent_id + ":auth:1"
notification_key = payment_intent_id + ":webhook:settled"Вывод по правилу, а не случайные ключи на каждом шаге, даёт свойство, ради которого стоит спорить на ревью: при реплее любой стадии ключи воспроизводятся сами, их не нужно хранить и передавать отдельным полем. Прогон конвейера заново с начала безопасно дойдёт до конца. Если для восстановления нужно точно установить, на каком шаге произошёл сбой, конвейер спроектирован неверно.
Когда откатить нельзя
После того как сообщение в схему ушло, откатить ничего нельзя. Остаётся компенсация — новое движение денег в обратную сторону. Разница с откатом не в терминах, а в отчётности: после отката в журнале нет одной записи, после компенсации в нём две — исходная и обратная, обе в выписке клиента.
Отсюда три требования. Компенсация сама идемпотентна, с ключом вида payment_intent_id + ":compensate", иначе её повтор спишет деньги дважды. Компенсируемый шаг идёт против зарезервированных средств, чтобы компенсация была выполнима всегда. И компенсация отличима в отчётности от обычного возврата — клиент и проверяющий должны видеть, что это техническая коррекция, а не бизнес-возврат.
Сверка как последний контур
Идемпотентность закрывает известные каналы повтора: ретрай клиента, повтор из брокера, перезалитый файл. Остальное ловит сверка, и это не бухгалтерская рутина.
Расхождения находятся всегда. Если сверка стабильно даёт ноль, она, скорее всего, сравнивает ваши данные с вашими же производными, а не с независимым источником. Расхождение нужно не только найти, но и объяснить: «не сошлось на 412 долларов» — не результат, результат — список операций с причиной по каждой. Сверка обязана быть двусторонней: проверять не только «наши операции есть у них», но и обратное. Самый опасный класс — операции, которые есть у них и отсутствуют у вас.
Исправление по итогам сверки — тоже движение денег. На него распространяется всё то же: свой ключ, запись в журнал, отличимость в отчётности.
Итог: ключевые выводы и практический совет
Идемпотентность — это свойство контракта между сторонами, а не отдельного вызова. Ключ намерения, выданный инициатором, должен дойти без изменений от внешней границы до проводки и до сообщения в платёжную схему. Retry сам по себе не делает систему устойчивее: он лишь ускоряет воспроизведение инцидента там, где контракта нет.
Отметка о ключе и сам эффект коммитятся в одной транзакции и в одной базе. Разнесение их по разным хранилищам открывает окно, в котором при повторе либо случается двойное списание, либо платёж теряется с ответом «успех». Однократность обеспечивает приёмник, а не канал, поэтому и входящий inbox, и исходящий outbox требуют собственных ключей, выведенных из исходного намерения.
Никакая дедупликация не закрывает все каналы повтора. Часть из них приходит мимо HTTP, без заголовков и контекста — файлом клиринга, повторной доставкой вебхука, реплеем журнала. Эти дыры ловит только сверка с независимым источником, причём двусторонняя: самый опасный класс — операции, которые есть у контрагента и отсутствуют у вас.
Практический совет: перед тем как выпустить конвейер в прод, задайте себе один вопрос — переживёт ли операция повтор, пришедший через сутки по другому каналу, без HTTP-контекста и в составе батча? Если ответ неочевиден, ключ выведен неправильно или окно дедупликации уже, чем ваш самый длинный канал повтора. Сверку сделайте обязательным контуром, а не задачей соседнего отдела: расхождение, найденное сверкой, дешевле расхождения, найденного клиентом.