Идемпотентность в платёжном конвейере: «повтори запрос» — это архитектура, а не retry

от автора

Платежный конвеер

Платежный конвеер

Начну со сцены, которую видел каждый, кто эксплуатировал платёжные сервисы.

Клиент нажимает «Оплатить». Приложение отправляет POST /payments. Проходит пять секунд, и HTTP-клиент падает по таймауту. Ретрай-политика, которую кто-то настроил год назад по гайду, ждёт двести миллисекунд и отправляет запрос ещё раз. Через минуту клиент видит в выписке два списания.

На разборе обычно приходят к одному из двух выводов. Оба неверные, хотя второй я сам когда-то считал правильным.

Первый вывод: надо выключить retry на POST. Хорошо, выключили. Теперь у нас платежи, про которые никто не знает, прошли они или нет. Поддержка разбирает их руками. Ущерб никуда не делся, он просто переехал на другой счёт.

Второй вывод: надо добавить заголовок Idempotency-Key. Добавили. Дедупликацию сделали в middleware поверх Redis. Через полгода двойное списание случается снова. Потому что Redis пережил failover и потерял ключи. Или потому что повтор пришёл через сутки из файла клиринга, а в файле никакого HTTP-заголовка нет.

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

Retry сам по себе всего лишь тактика. Он работает там, где контракт уже есть. Без контракта retry не делает систему устойчивее. Он делает её быстрее ломающейся.

Почему в деньгах цена ошибки несимметрична

Есть мысль, которую стоит проговорить в самом начале, потому что из неё выводится почти всё остальное.

В обычной системе потерять запрос и задвоить запрос примерно одинаково плохо. В платёжной системе это не так.

Потерянный платёж всего лишь неудобство. Клиент повторит, оператор найдёт, деньги никуда не делись.

Задвоенный платёж означает, что мы списали чужие деньги без основания. Дальше возвраты, регуляторные сроки расследования, подорванное доверие к выписке. И чаще всего находит задвоение не наша система, а клиент. Потом ему придётся объяснять, почему наша сверка ничего не заметила.

Отсюда правило, к которому я буду возвращаться: при неопределённости конвейер останавливается и выясняет, что произошло. Он не повторяет. Это называется fail closed.

Четыре вещи, которые называют идемпотентностью

Термин затаскан. Под ним понимают несколько разных вещей, и пока их не развести, разговор рассыпается.

Абсолютные операции и дельты. Формально идемпотентность означает f(f(x)) = f(x): применить дважды всё равно что применить один раз. Команда SET balance = 1000 идемпотентна. Команда balance = balance - 100 нет. Первая задаёт состояние, вторая задаёт изменение. Деньги устроены как изменения: сумма перевода имеет смысл, а «установить баланс в X» смысла не имеет, потому что между чтением и записью прошли ещё три операции.

Отсюда следствие, которое мне самому не нравится. Платёжные операции не бывают идемпотентными сами по себе. Их приходится делать такими искусственно, через идентификатор намерения. Другого способа нет.

Эффект и ответ. Что проводка создана один раз, это одна гарантия. Что повторный запрос получит тот же ответ, что и первый, это другая гарантия, независимая. Если есть первая, но нет второй, клиент получает на повтор ошибку 409 и решает, что платёж не прошёл. Дальше он либо показывает пользователю ошибку при уже списанных деньгах, либо запускает отмену операции, которая на самом деле состоялась. Поэтому на повтор с тем же ключом сервер возвращает ровно тот же ответ: тот же код, то же тело. Можно добавить заголовок вроде Idempotent-Replayed: true, но не больше.

Дубли и порядок. Идемпотентность защищает от дублирования, но не от перестановки сообщений. Здесь есть связь, которую я долго не замечал. Если система хранит не «текущий баланс», а неизменяемые проводки, каждая со своим ключом, то она устойчива и к дублям, и к перестановкам. Дубли отсекает ключ, а порядок не важен, потому что сложение коммутативно. Одно решение закрывает два класса отказов.

Exactly-once. В сети, которая теряет пакеты, доставить сообщение ровно один раз нельзя. Невозможно отличить потерю запроса от потери ответа. Остаются два варианта: не повторять и терять, либо повторять и получать дубли. Всё, что промышленно называется exactly-once, включая транзакции Kafka, это второй вариант плюс дедупликация на получателе.

Однократность всегда обеспечивает приёмник, а не канал. Её нельзя купить выбором брокера.

Откуда берутся повторы

Когда говорят про идемпотентность, инженер представляет себе цикл for с тремя попытками в своём коде. В живом конвейере это малая часть источников. Вот что видел я.

Явные повторы понятны. Ретрай в клиентской библиотеке или в SDK вендора. Очередь, которая вернула упавшую задачу. Брокер сообщений, который доставляет at-least-once: потребитель упал после обработки, но до коммита оффсета, и сообщение пришло снова. Оператор поддержки, нажавший «Повторить», потому что статус выглядел зависшим.

Неявные повторы опаснее. HTTP-клиенты и прокси по умолчанию переотправляют запрос, если соединение оборвалось до ответа. Стандарт это разрешает для идемпотентных методов, но конфигурация обычно не различает POST и PUT. Отдельная история с keep-alive: сервер закрывает простаивающее соединение ровно в тот момент, когда клиент в него пишет. Клиент видит connection reset и не знает, дошёл ли запрос. Ретрай на сервис-меше включает человек из платформенной команды, который ваш платёжный сервис не читал. Плюс двойной клик, кнопка «Назад», восстановленная вкладка.

И третья группа, про которую я в первые годы не думал вообще. Это повторы, которые приходят не по тому каналу, что оригинал. Файл клиринга не приняли из-за формата, исправили, отправили заново, а половина записей уже обработана. Вендор повторно доставил вебхук. После инцидента запустили реплей журнала «с запасом». Бэкфилл пересёкся с онлайновым потоком.

Если бы повторы порождал только клиентский код, задачу решала бы дисциплина клиента. Но повтор может прийти через сутки, по другому транспорту, без HTTP-контекста и в составе батча.

Дедупликация не может жить в HTTP-middleware. Она живёт там же, где живёт эффект: рядом с проводкой, в той же базе, в той же транзакции.

Третье состояние

Большинство кода моделирует исход вызова двумя состояниями: успех и ошибка. В платёжной системе состояний три: успех, отказ и неопределённость. Таймаут, обрыв соединения, ответ 502 или 504, падение собственного процесса между отправкой и получением. Всё это не ошибка. Это отсутствие информации.

Из этого следует требование к API, которое нарушают удивительно часто. Для каждой операции, которая меняет состояние, должен быть способ спросить: что стало с моим намерением X. Без такого метода неопределённость неразрешима, остаётся только угадывать.

Карточные сети поняли это десятилетия назад. На таймаут авторизации там предписан не повтор запроса, а reversal, отдельное сообщение, которое снимает возможный холд. В SWIFT есть сквозной идентификатор платежа, по которому запрашивают статус. В ACH есть trace number.

А внутренние сервисы этот урок теряют. Типичный платёжный API имеет POST /payments и не имеет GET /payments?client_reference=.... При таймауте вызывающая сторона физически не может узнать исход. Поэтому метод чтения по клиентскому идентификатору проектируется одновременно с методом записи, а не «когда понадобится». Понадобится он в первый же серьёзный инцидент, и делать его придётся в спешке.

Ключ

Вся конструкция держится на идентификаторе намерения. Если он выбран правильно, остальное техника. Если нет, техника не спасёт.

Чего мы хотим от ключа

Ключ генерирует инициатор, а не сервер. Сервер не отличит повтор от нового намерения, если сам же и выдаёт ключи.

Ключ создаётся до первой попытки и переиспользуется всеми повторами.

Ключ уникален по намерению, а не по содержимому. Два одинаковых платежа одному получателю это два разных намерения, и оба должны пройти.

Ключ живёт дольше, чем горизонт повторов. Если он хранится в памяти процесса, после перезапуска старое намерение получит новый ключ.

Ключ ничего не значит для получателя. Из ключа, который что-то содержит, это что-то рано или поздно начнут парсить.

Откуда его взять

Есть три варианта.

Клиентский ключ. 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"

ledger_entry_key = payment_intent_id + ":debit"

scheme_message_key = payment_intent_id + ":auth:1"

notification_key = payment_intent_id + ":webhook:settled"

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

Как ключ выбирают неправильно

Хэш тела запроса. Выглядит элегантно, от клиента ничего не нужно. Ломается дважды. Тело почти никогда не стабильно: в нём есть timestampnonce, порядок полей зависит от сериализатора. Один изменившийся байт, и повтор считается новым намерением. А если тело стабильно, хэш склеивает два легитимных одинаковых платежа. Клиент дважды перевёл пятьсот рублей одному человеку, второй перевод молча пропал. Это уже не защита, а потеря денег, только в другую сторону.

Генерация ключа внутри цикла повторов.

for (var attempt = 0; attempt < 3; attempt++){    var key = Guid.NewGuid().ToString();   // новый ключ на каждой попытке    try { return await client.PayAsync(request, key, ct); }    catch (TimeoutException) { }}

{

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. Без нового эффекта и без старого ответа. Старый ответ здесь опаснее ошибки: клиент решит, что прошёл его новый запрос с новой суммой.

Как это устроено на сервере

Самая частая реализация выглядит так.

if (await store.ExistsAsync(key, ct))    return await store.GetResponseAsync(key, ct);var result = await ProcessAsync(request, ct);await store.SaveAsync(key, result, ct);

return await store.GetResponseAsync(key, ct);

var result = await ProcessAsync(request, ct);

await store.SaveAsync(key, result, ct);

Два запроса с одним ключом, пришедшие одновременно, оба пройдут проверку и оба выполнят операцию. Это классическая гонка между проверкой и действием. Срабатывает она в худший момент: параллельные повторы появляются из-за таймаутов, а таймауты приходят пачками под нагрузкой.

Правильный подход: не проверять, а сразу пытаться записать. Уникальность обеспечивает база.

Таблица и захват ключа

create table idempotency_record (    tenant_id       uuid        not null,    endpoint        text        not null,    idem_key        text        not null,    request_digest  bytea       not null,    state           smallint    not null,   -- 0 in_progress, 1 completed    lease_until     timestamptz,    response_status int,    response_body   jsonb,    created_at      timestamptz not null default now(),    primary key (tenant_id, endpoint, idem_key));

tenant_id uuid not null,

endpoint text not null,

idem_key text not null,

request_digest bytea not null,

state smallint not null, -- 0 in_progress, 1 completed

lease_until timestamptz,

response_status int,

response_body jsonb,

created_at timestamptz not null default now(),

primary key (tenant_id, endpoint, idem_key)

);

Состояние in_progress здесь принципиально. Оно означает: попытка началась, исход неизвестен. Без него параллельный повтор неотличим от первого запроса.

Захват выглядит так.

insert into idempotency_record (tenant_id, endpoint, idem_key, request_digest, state, lease_until)values (@tenant, @endpoint, @key, @digest, 0, now() + @lease)on conflict (tenant_id, endpoint, idem_key) do update   set lease_until = excluded.lease_until where idempotency_record.state = 0   and idempotency_record.lease_until < now()returning state, request_digest, response_status, response_body, (xmax = 0) as inserted;

values (@tenant, @endpoint, @key, @digest, 0, now() + @lease)

on conflict (tenant_id, endpoint, idem_key) do update

set lease_until = excluded.lease_until

where idempotency_record.state = 0

and idempotency_record.lease_until < now()

returning state, request_digest, response_status, response_body, (xmax = 0) as inserted;

Возможны три исхода. Строка вернулась с inserted = true: мы первые, применяем эффект. Строка вернулась с inserted = false: мы перехватили зависшую попытку, о них ниже. Строка не вернулась вовсе: ключ занят. Тогда читаем его отдельным запросом. Если операция завершена, сверяем отпечаток и отдаём сохранённый ответ. Если она ещё выполняется, отвечаем 409 с заголовком Retry-After.

Про 409 скажу отдельно. Ждать завершения параллельной попытки, удерживая соединение, не нужно. Ожидание превращает всплеск повторов в исчерпание пула соединений. Честный 409 дешевле, хотя клиентам он не нравится.

Одна транзакция

Вот ядро всей конструкции.

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);   // replay / 409 / 422var 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);return response;

var claim = await TryClaimAsync(conn, tx, scope, key, digest, ct);

if (claim is not ClaimResult.Acquired)

return await HandleNotAcquiredAsync(claim, tx, ct); // replay / 409 / 422

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);

return response;

Отметка о том, что ключ отработан, и сам эффект коммитятся одной транзакцией.

Если разнести их по разным транзакциям, появляется окно. В одном случае эффект есть, а ключ не помечен: падение здесь даёт двойное списание при повторе. В другом ключ помечен, а эффекта нет: платёж потерян навсегда, причём с ответом «успех». Оба окна маленькие. Оба обязательно срабатывают, если умножить вероятность на миллион транзакций.

Следствие, которое многим не нравится: таблица идемпотентности живёт в той же базе, что и леджер. Отдельный «сервис идемпотентности» выглядит красиво, но ломает это правило.

Зависшие попытки

Процесс упал между захватом ключа и коммитом. Строка осталась в in_progress. Что делать?

Наивный ответ «истёк TTL, значит операции не было» неверен. Если внутри попытки был внешний вызов, деньги могли уйти.

Если эффект локальный и коммитился вместе с ключом, зависшая строка означает откат. Повтор безопасен. Если был внешний вызов, сначала спрашиваем внешнюю систему по её ключу. Продолжаем только с определённым ответом. Если внешняя система не умеет отвечать на такой вопрос, операция уходит в ручной разбор. Неприятно, но честно.

И поставьте алерт на возраст самой старой записи в in_progress. Это самый ранний признак того, что где-то ниже по конвейеру начались таймауты.

Redis и последний рубеж

Redis с SET key NX EX годится как кэш перед базой: он снимает нагрузку при массовых повторах. Как источник истины он не годится. Репликация асинхронная, при failover подтверждённая запись может пропасть. Общей транзакции с проводкой в PostgreSQL не бывает. И ключ истекает молча, так что при разборе инцидента нечем доказать, что дедупликация сработала.

Схема получается такой: Redis как фильтр, база как арбитр, уникальный индекс в самой таблице проводок как последний рубеж.

Про последний рубеж одно важное замечание. Уникальный индекс на (tenant, external_reference) в таблице проводок обязателен всегда. Но его срабатывание означает не «защита отработала», а «выше по стеку есть баг». Следующий такой дубликат может прийти по пути, где индекса нет. Эту метрику считают отдельно, и она должна быть нулём.

Окно дедупликации

Хранить ключи вечно нельзя, значит есть окно. Ответ «сутки, как у Stripe» это не решение, а заимствование чужих условий. Окно Stripe рассчитано под их профиль повторов, клиентские HTTP-ретраи. Ваш профиль почти наверняка шире.

Окно покрывает самый длинный канал повтора, который вы допускаете.

HTTP-ретрай живёт секунды. Повтор из брокера часы. Повторный вебхук вендора до суток. Перезалитый файл клиринга дни. Ручной повтор оператора недели. Если файл могут перезалить через двое суток, окно меньше двух суток бессмысленно.

На границе окна повтор превращается в новую операцию и второе списание. Поэтому уровней защиты два. Ключ идемпотентности защищает внутри окна и умеет вернуть сохранённый ответ. Естественный ключ на бизнес-уровне, тот самый уникальный индекс в таблице проводок, защищает без всякого TTL. Первый обеспечивает правильный ответ, второй правильный эффект. Второй дешевле и живёт дольше.

Ловушка с партиционированием

На неё я наступил сам. Хочется партиционировать таблицу ключей по времени и удалять старые партиции целиком, а не построчно. Но в PostgreSQL уникальный индекс на партиционированной таблице обязан включать ключ партиционирования. Если партиционировать по created_at, первичный ключ расширяется до (tenant_id, endpoint, idem_key, created_at). Глобальная уникальность по ключу исчезает. Повтор, пришедший завтра, ляжет в другую партицию и будет принят как новая операция.

Оптимизация удаления тихо отключает гарантию, ради которой таблица создавалась. Выход: партиционировать по хэшу ключа или не партиционировать вовсе. Это стоит записать в decision record, потому что через год причина будет неочевидна.

Внешние системы

Пока эффект локален, конструкция выше замкнута. Но платёжный конвейер локальным не бывает. Есть платёжная схема, банк-корреспондент, вендор скрининга. Записать проводку у себя и отправить сообщение наружу атомарно нельзя.

Порядок при этом важен. Если сначала внешний вызов, а потом запись, то при падении между ними деньги ушли, а следа нет. Этого не увидит даже сверка, пока не придёт выписка. Если сначала запись, а потом вызов, платёж «висит», но восстановим. Поэтому всегда второй порядок: сначала зафиксировать намерение, потом действовать.

Outbox не решает проблему, а переносит

Стандартное решение называется transactional outbox. В одной транзакции с проводкой пишется строка в таблицу исходящих сообщений. Отдельный процесс читает её и отправляет наружу.

Про outbox важно понимать одно. Отправитель может упасть между отправкой и отметкой «отправлено», и тогда отправит снова. То есть outbox гарантирует at-least-once, а значит, получатель обязан быть идемпотентным. Ключ для получателя выводится из намерения и хранится в самой строке outbox, чтобы при повторе уйти тем же. Это и есть та транзитивность контракта, с которой я начал.

На входе всё симметрично: таблица inbox с insert ... on conflict do nothing по идентификатору сообщения, в той же транзакции, что и результат обработки. Только не дедуплицируйте по оффсету в Kafka. Оффсет не свойство сообщения, он меняется при ребалансе и повторной публикации. Дедупликация возможна только по идентификатору, который назначил источник.

И про транзакции Kafka. Они атомарны внутри Kafka. Как только обработка включает запись в базу или HTTP-вызов, гарантия на это не распространяется. Режим брокера не заменяет дедупликацию на приёмнике.

Частично применённая операция

Это самое трудное. Дублирование целой операции решается ключом. Частичное применение нет.

Платёж это не один эффект, а цепочка: резерв на счёте, проводка, сообщение в схему, подтверждение, уведомление. Сбой возможен между любыми двумя шагами. Ключ на входе гарантирует, что цепочка не запустится дважды с нуля. Он не гарантирует, что она завершится.

Если применена часть шагов внутри нашей системы, помогает конечный автомат с сохранением состояния после каждого шага. Но при одном условии: каждый шаг сам идемпотентен и имеет свой ключ, выведенный из намерения. Тогда конвейер можно просто прогнать заново с начала, и он безопасно дойдёт до конца.

Если для восстановления нужно точно установить, на каком шаге произошёл сбой, конвейер спроектирован неверно.

Если эффект есть у внешней стороны, а у нас нет, помогают только две вещи. Запрос статуса по сквозному идентификатору и ежедневная сверка. Поэтому сверка не бухгалтерская рутина, а последний контур целостности. Я долго считал иначе.

Компенсация вместо отката

Когда сообщение в схему ушло, а следующий шаг не прошёл, откатить ничего нельзя. Можно только компенсировать: сделать новое движение денег в обратную сторону.

Разница с откатом не в терминах, а в отчётности. После отката в журнале нет одной записи. После компенсации в нём две: исходная и обратная, обе в выписке клиента.

Отсюда три требования. Компенсация сама должна быть идемпотентной, с ключом вида payment_intent_id + ":compensate", иначе её повтор спишет деньги дважды. Компенсация должна быть выполнима всегда, поэтому компенсируемый шаг идёт против зарезервированных средств. И компенсация должна быть отличима в отчётности от обычного возврата: клиент и проверяющий должны видеть, что это техническая коррекция.

Леджер только на добавление

Есть модель хранения, которая снимает половину этих проблем.

-- не такupdate account set balance = balance - 100 where id = @id;-- такinsert into ledger_entry (entry_key, account_id, direction, amount, currency, value_date, source_ref)values (@entryKey, @account, 'D', 100, 'USD', @valueDate, @paymentId);-- уникальный индекс на (tenant_id, entry_key)

update account set balance = balance - 100 where id = @id;

-- так

insert into ledger_entry (entry_key, account_id, direction, amount, currency, value_date, source_ref)

values (@entryKey, @account, 'D', 100, 'USD', @valueDate, @paymentId);

-- уникальный индекс на (tenant_id, entry_key)

update не оставляет следа. После повторного применения нельзя отличить «списали дважды» от «списали один раз другую сумму». Журнал только на добавление, с ключом на каждой записи, делает баланс вычисляемой величиной: сумма кредитов минус сумма дебетов. Для скорости её материализуют, но всегда можно пересчитать из первички.

Что это даёт. Дедупликация проводки совпадает с дедупликацией эффекта. Порядок применения не важен. Состояние восстанавливается на любую дату. У каждой записи есть source_ref, по которому видно, откуда она взялась.

Чем платим. Объём только растёт. Чтение баланса дороже и требует контроля согласованности проекции с первичкой. Исправить ошибку можно только новой записью, и бизнесу придётся объяснять, почему «просто поправить» нельзя. Персональные данные в такой журнал класть нельзя, только идентификаторы и суммы.

Для платёжного конвейера этот компромисс почти всегда правильный.

Когда retry без ключа делает двойное списание

Сведу в таблицу. «Повтор без ключа» означает то, что делает типичная retry-политика из коробки.

Что случилось

Что известно

Повторять без ключа

Connection refused, ошибка DNS или TLS

Байты не ушли

Можно

Таймаут ожидания ответа

Запрос ушёл, исход неизвестен

Нельзя

Connection reset после отправки

Запрос мог дойти

Нельзя

500

Ошибка могла случиться после эффекта

Нельзя

502504

Прокси не дождался, бэкенд мог отработать

Нельзя

429

Запрос отклонён до обработки

Можно, с паузой

409 от идемпотентного эндпоинта

Параллельная попытка с тем же ключом

Можно, с паузой

400422401403

Запрос некорректен

Бессмысленно

Неприятный вывод из таблицы: без ключа безопасно повторять можно только первую строку. Только там точно известно, что запрос не покинул хост. Всё остальное неопределённость.

Три сценария, где это срабатывает чаще всего.

Дефолтная политика «повторить при 5xx и сетевой ошибке», применённая к POST /payments без ключа. Она даёт задвоение не всегда, а только когда запрос успел дойти. То есть редко, нерегулярно и невоспроизводимо в тесте. Именно поэтому она такая живучая.

Ретрай на сервис-меше. Для прокси POST /payments и GET /health одинаковые запросы. Ретраи на меше включают только для путей, явно помеченных как идемпотентные.

Реплей после инцидента. Восстановление начали «с запасом», чтобы точно ничего не потерять, и переиграли уже применённое. По моему опыту это самый частый источник массовых дублей. Происходит в спешке, руками, в три ночи. Защита та же: ключи, выведенные из намерения, при которых реплей безопасен по построению. И репетиция восстановления до того, как оно понадобится.

И последнее. Часть повторов вообще вне вашего контроля. Их делает платёжная схема, вендор, браузер пользователя. Просить их не повторять бессмысленно.

Вы не управляете повторами. Вы управляете только тем, что происходит при повторе.

Что придумала индустрия до нас

Платёжные стандарты старше почти всех наших сервисов и прошли те же грабли с более дорогой ценой ошибки.

ISO 8583, карточный протокол. Запрос и ответ сопоставляются по номеру STAN (поле 11). Номер RRN (поле 37) живёт от авторизации через клиринг до спора. Повторная передача идёт с теми же номерами: повтор объявлен повтором, а не замаскирован под новую операцию. На таймаут авторизации правила сетей предписывают не повтор, а reversal, отдельное сообщение, которое снимает возможный холд. И сам reversal повторяется до подтверждения, а значит, должен быть идемпотентным на приёмнике.

ISO 20022 и SWIFT gpi. Здесь идентификаторы разнесены по уровням. InstrId действует между соседними сторонами. EndToEndId идёт от инициатора через всю цепочку без изменений. UETR, сквозной UUID, обязателен в платёжных сообщениях SWIFT с 2018 года, по нему запрашивают статус платежа. Это ровно та схема ключей, что и в разделе про гибрид: ключ инициатора на всю длину плюс ключ для соседнего звена. Отмена здесь тоже отдельное сообщение, а не откат.

ACH. Каждая запись несёт trace number. Самые дорогие инциденты происходят при повторной отправке файла: это не одно задвоение, а десятки тысяч. Отсюда правило для любых файловых интеграций.

Дедупликация делается на уровне записи, а не файла. Файл всего лишь транспорт.

Проверка «этот файл уже грузили» по контрольной сумме ловит точное повторение и промахивается мимо самого частого случая: исправленного и перезалитого файла.

Что стоит забрать из стандартов. Сквозной идентификатор намерения. Явный признак повтора. При неопределённости приведение к известному состоянию вместо повтора. Идемпотентный механизм восстановления. Компенсация как отдельная запись. Современные внутренние API удовлетворяют этим требованиям, честно говоря, реже, чем ISO 8583.

Как это тестировать

Обычные интеграционные тесты идемпотентность не проверяют. В тестовой среде сеть не рвётся, процессы не падают. Проверять нужно специально.

Самый дешёвый приём: прогнать весь набор интеграционных тестов в режиме, где каждый исходящий вызов выполняется дважды.

internal sealed class DoubleSendHandler : DelegatingHandler{    protected override async Task<HttpResponseMessage> SendAsync(        HttpRequestMessage request, CancellationToken ct)    {        var copy   = await request.CloneWithBufferedContentAsync(ct);        var first  = await base.SendAsync(request, ct);        var second = await base.SendAsync(copy, ct);        AssertSameOutcome(first, second);     // тот же статус, то же тело        return second;    }}

{

protected override async Task<HttpResponseMessage> SendAsync(

HttpRequestMessage request, CancellationToken ct)

{

var copy = await request.CloneWithBufferedContentAsync(ct);

var first = await base.SendAsync(request, ct);

var second = await base.SendAsync(copy, ct);

AssertSameOutcome(first, second); // тот же статус, то же тело

return second;

}

}

Такой же декоратор ставится на публикатор сообщений. Критерий приёмки простой: балансы, число проводок и число сообщений в схему совпадают с обычным прогоном. Любое расхождение это баг, найденный до прода. Держите этот прогон отдельной сборкой в CI.

Для частичного применения нужны прицельные отказы: обрыв после коммита, но до ответа; после внешнего вызова, но до записи результата; между шагами цепочки. В коде стоят именованные точки, в проде они ничего не делают, а в тесте в них можно убить процесс.

Отдельно проверяются параллельные запросы с одним ключом на реальной базе, а не на заглушке. Заглушка не воспроизводит поведение уникального индекса под конкуренцией.

И каждый инцидент с дублями из прода превращается в регрессионный тест навсегда. Через два года никто не вспомнит, зачем в конвейере эта проверка. Тест вспомнит.

Сверка и разбор инцидентов

Метрик нужно немного. Число отсечённых повторов по причинам. Возраст самой старой записи в in_progress. Доля запросов без ключа на изменяющие эндпоинты. Число компенсаций. И срабатывания уникального индекса в леджере, которых должно быть ноль.

Про сверку скажу то, что вынес из её эксплуатации у эмитента карт, где она у меня работает сейчас.

Расхождения находятся всегда. Если сверка стабильно даёт ноль, она скорее всего сравнивает наши данные с нашими же производными, а не с независимым источником.

Расхождение нужно не только найти, но и объяснить. «Не сошлось на 412 долларов» не результат. Результат это список операций с причиной по каждой.

Сверка должна быть двусторонней. Проверять только «наши операции есть у них» недостаточно. Самый опасный класс это операции, которые есть у них и отсутствуют у нас.

Исправление по результатам сверки тоже движение денег. На него распространяется всё сказанное: свой ключ, запись в журнал, отличимость в отчётности.

Идемпотентность закрывает только известные каналы повтора. Сверка ловит остальное. Мне понадобилось несколько лет, чтобы принять это, потому что сверка выглядит как задача другого отдела.

Если задвоение уже случилось

Порядок действий лучше написать заранее. Остановить источник. Определить полный периметр, а не «сколько жалоб»: жалуются единицы процентов затронутых. Компенсировать новыми записями с ключами. Уведомить клиентов, в регулируемой среде сроки на это заданы нормативно. И сохранить след разбора: что произошло, какие операции, какие компенсации, кто принял решение. Через полгода это понадобится проверяющему.

Дедупликация обязана оставлять запись.

Если система молча отбросила повтор, то на вопрос «мы отправили два запроса, списание одно, что случилось?» ответить будет нечем. Каждое срабатывание пишется в журнал с ключом, временем, источником и ссылкой на исходный эффект.

Что написать в документации

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

Формулировка, которую я бы держал в спецификации дословно:

POST /payments принимает заголовок Idempotency-Key (UUID, обязателен). Ключ уникален в пределах учётной записи и эндпоинта. Повтор с тем же ключом в течение 7 суток возвращает исходный ответ с заголовком Idempotent-Replayed: true. Повтор с тем же ключом и другим содержимым возвращает 422 с кодом idempotency_key_reuse, эффект не применяется. Параллельный запрос с тем же ключом возвращает 409 с Retry-After. По истечении 7 суток ключ не распознаётся, и запрос будет обработан как новое поручение. Для проверки статуса используйте GET /payments?client_reference=....

Последнее предложение самое ценное. И самое редкое в реальной документации.

Вместо заключения

Идемпотентность это свойство контракта, а не вызова. Она начинается на клиенте, где рождается идентификатор намерения, и должна дойти без изменений до проводки и до сообщения в схему.

Retry без ключа это не устойчивость, а ускоренное воспроизведение инцидента. Без ключа безопасно повторять только то, что точно не покинуло хост.

Отметка о ключе и эффект коммитятся вместе. Однократность обеспечивает приёмник, а не канал. Механизм восстановления сам движет деньги и сам нуждается в ключе. И сверка всё равно обязательна.

Всё это сводится к одному вопросу, ответ на который потом очень дорого менять: что в вашей системе является идентичностью платежа? Если ответ «идентификатор намерения, присвоенный инициатором и живущий на всей длине конвейера», дальше почти всё выводится само. Если ответа нет, никакая retry-политика его не заменит.


Почитать: RFC 9110, раздел о свойствах методов; черновик IETF Idempotency-Key; ISO 8583 и правила сетей по reversal; ISO 20022, структура PmtId и UETR; Pat Helland, «Life beyond Distributed Transactions».

ссылка на оригинал статьи https://habr.com/ru/articles/1080814/