← Все статьи

SiteGuard12 мин чтения

Дубли заказов и платежей: как работает идемпотентность запросов

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

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

Сначала определите одну логическую операцию

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

В материале AWS Builders’ Library объясняется подход с идентификатором, который явно передаёт намерение вызывающей стороны. Сравнения одних параметров недостаточно: одинаковое содержимое может соответствовать разным законным операциям.

Разделите номер заказа, идентификатор операции и идентификатор попытки HTTP. Один заказ может иметь отдельные операции создания платежа, подтверждения и возврата. Ключ относится к выбранной операции. Request ID помогает найти конкретное обращение в журналах и обычно меняется между попытками; сам по себе он не заменяет бизнес-идентификатор.

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

Создайте ключ до первой отправки и сохраните его

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

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

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

Проверьте поведение SDK и обёртки над HTTP-клиентом при повторах. Важно не только наличие заголовка в первой попытке, но и сохранение того же значения при повторной отправке из очереди или после восстановления приложения.

Как работает Idempotence-Key в ЮKassa

В формате взаимодействия ЮKassa указано точное имя заголовка: Idempotence-Key. Значение должно быть уникальным для операции на вашей стороне и содержать не больше 64 символов; рекомендован UUID v4. Ключ передают в POST- и DELETE-запросах. GET-запросы описаны как идемпотентные по умолчанию.

При одинаковых данных и ключе ЮKassa распознаёт повтор. Одинаковые данные с другим ключом обрабатываются как новый запрос. Поэтому недостаточно добавить Idempotence-Key в заголовки: приложение должно сохранять связь этого значения с операцией и восстанавливать её при следующей попытке.

Для этого провайдера документировано окно идемпотентности 24 часа после первого запроса. После него повтор обрабатывается как новый. Этот срок нужно учитывать в восстановлении; он не означает, что внутреннюю историю заказа можно удалить спустя сутки.

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

Timeout означает неизвестный результат, а не отсутствие операции

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

В документации HTTP-кодов ЮKassa отдельно указано, что HTTP 500 не подтверждает ни успех, ни неуспех операции. Для выяснения результата предлагается повтор с теми же данными и ключом либо GET-запрос информации об известном объекте. Повторные обращения выполняются с увеличивающимся разумным интервалом.

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

Для подтверждённой ошибки HTTP 400 ЮKassa рекомендует исправить запрос и повторить с новым ключом. Это другой сценарий: известна ошибка запроса и изменяются данные. Его нельзя использовать как универсальное правило действий после сетевого таймаута.

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

Задайте область ключа и правила совпадения параметров

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

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

Для своего API заранее выберите явный ответ при таком конфликте и опишите его клиентам. Сравнивайте нормализованные значимые данные: порядок полей JSON не должен случайно превращать эквивалентный запрос в другой. При этом нормализация не должна стирать различия, важные для операции.

Не выводите ключ только из суммы и состава корзины. Это может объединить разные намеренные покупки. Отпечаток параметров полезен для проверки согласованности уже выбранного ключа, а не как универсальная замена идентичности операции.

Защитите начало обработки от конкурентных повторов

Проверка «есть ли ключ в таблице» и последующая вставка отдельными действиями недостаточны. Два процесса могут одновременно не найти запись и оба начать работу. Нужна атомарная фиксация права обработать операцию: конкурентный повтор должен увидеть уже существующее состояние.

Один из инструментов — уникальное ограничение в хранилище и обработка конфликта вставки. Например, PostgreSQL описывает ON CONFLICT для работы с конфликтующими уникальными значениями. Но наличие такой конструкции не делает всю бизнес-операцию автоматически атомарной: важно, какие изменения объединены транзакцией.

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

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

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

Храните состояние и результат, которые понятны повторному запросу

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

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

Разделяйте результат API-вызова и последующее состояние внешнего объекта. Получение идентификатора созданного платежа ещё не подтверждает завершение оплаты. Сохранённая связь нужна, чтобы узнать актуальное состояние того же объекта, а не начать другой.

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

Разберите сбои между действием и фиксацией результата

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

Момент сбояЧто известноБезопасное направление восстановления
До отправки внешнего запросаНамерение и ключ сохраненыПродолжить эту операцию с сохранёнными данными
После отправки, до получения ответаВнешний результат неизвестенСверка либо предусмотренный повтор с тем же ключом
После внешнего результата, до локальной записиЛокальная история неполнаВосстановить связь с существующим объектом
После локальной фиксации, до ответа клиентуРезультат уже сохранёнВернуть его при повторном обращении

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

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

Закройте разрыв между записью заказа и отправкой сообщения

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

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

У отправителя остаётся собственная граница: сообщение опубликовано, но отметка об этом ещё не записана. После сбоя он может отправить его снова. Поэтому получателю всё равно нужна защита от повторного эффекта; outbox не превращает все распределённые действия в exactly once.

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

Отдельно учитывайте дубли webhook

Повтор API-запроса и повтор уведомления — разные входные потоки. Первый пытается выполнить операцию, второй сообщает об изменении уже существующего объекта. Ключ исходящего запроса не заменяет идентификатор входящего события и правило применения уведомления.

Для webhook сохраните предусмотренную провайдером идентичность события, связь с объектом и факт обработки. Если отдельного event ID нет, правило определения повторов проектируют по формату и смыслу конкретного уведомления. Нельзя считать любое сообщение об одном объекте лишним: оно может отражать другое состояние.

Проверьте взаимодействие двух путей обновления: ответ исходного API и входящее уведомление могут сообщить об одном результате. Обновление заказа и запуск следующего действия должны оставаться согласованными независимо от порядка этих сообщений.

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

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

Учитывайте TTL и поздние повторы

Срок хранения ключа ограничивает защиту от позднего повтора. Для ЮKassa документированы 24 часа после первого запроса; после этого тот же запрос может быть обработан как новый. Длительная внутренняя задержка очереди способна вывести попытку за это окно.

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

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

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

Учебный пример: три попытки и один результат

Предположим, сайт начинает операцию op-71 для тестового заказа order-demo-42. До отправки он сохраняет один ключ key-demo-71. Все попытки выполняются с одинаковыми значимыми параметрами в пределах срока, предусмотренного интеграцией.

ПопыткаКлюч операцииНаблюдениеДействие сайта
request-a, 10:00:00key-demo-71Ответ не получен вовремяОтметить неизвестный результат
request-b, 10:00:02key-demo-71Получен объект provider-demo-17Сохранить связь с op-71
request-c, 10:00:05key-demo-71Подтверждён тот же объектПродолжить существующую операцию

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

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

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

Как проверить защиту от дублей перед релизом

Проверяйте поведение на тестовых объектах с контролируемыми отказами. Одного успешного повтора после завершения операции недостаточно: основные риски проявляются одновременно с обработкой и после остановки процесса.

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

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

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