Идемпотентность в UI и API: как защититься от двойных кликов и сетевых повторов в операциях «создать/оплатить» с ключами, блокировкой и повторами.

Повторы чаще всего появляются там, где человек ждет мгновенный результат, а система отвечает с задержкой. Пользователь нажимает кнопку еще раз, обновляет страницу или возвращается назад и повторяет действие. Одно намерение превращается в два (или больше) одинаковых запроса.
Самый понятный сценарий - двойной клик в интерфейсе. Кнопка выглядит активной, подтверждения нет, рука нажимает снова. В мобильных приложениях это может быть двойной тап или повторное нажатие после короткого подвисания.
Не менее частая причина - сеть и повторы на стороне клиента и инфраструктуры. Если соединение провалилось на секунду, приложение может автоматически повторить запрос. Пользователь тоже повторит действие, потому что не увидел ответа. При этом первый запрос мог успешно дойти до сервера и выполниться, а ответ просто потерялся.
Сильнее всего страдают операции, которые меняют состояние: «создать» и «оплатить». Появляется новый заказ, списываются деньги, бронируются остатки, создается доставка. Если действие выполнится дважды, последствия заметны сразу.
Для пользователя это выглядит просто и очень болезненно:
Проблема не только в деньгах. Повторы ломают учет (склад, бонусы, лимиты), портят аналитику и увеличивают нагрузку на поддержку. На «быстрый интернет» и «аккуратных пользователей» рассчитывать нельзя: задержки, автоповторы и случайные клики будут всегда, особенно на мобильной связи.
Идемпотентность - это свойство действия, когда повтор одного и того же запроса не меняет результат и не приносит вреда. Представьте кнопку «Оплатить»: вы нажали один раз, а потом еще раз из-за подвисания. Идемпотентное поведение означает, что система должна отработать так, будто было одно нажатие.
Практическое правило: один результат на одно намерение пользователя. Намерение - «я хочу создать один заказ» или «я хочу оплатить этот заказ один раз», а не «я хочу отправить запрос дважды». Повторы почти всегда появляются не по желанию человека, а из-за интерфейса, плохой связи или автоматических ретраев на стороне клиента.
Идемпотентность решают на двух уровнях:
Некоторые операции «по природе» проще сделать идемпотентными. Повторные «получить» (прочитать данные) обычно безопасны. «Обновить» тоже часто можно сделать безопасным, если объект однозначно определяется (например, обновить профиль по id).
А вот «создать» и «оплатить» по умолчанию не идемпотентны: повтор может создать второй заказ или списать деньги второй раз. Поэтому для них почти всегда нужен явный механизм, например ключ идемпотентности, который связывает все повторы с одной попыткой.
Если вы собираете форму заказа в TakProsto и добавляете кнопку «Создать и оплатить», думайте не про «сколько запросов ушло», а про «сколько реальных действий должно случиться». Это и есть смысл идемпотентности.
Повторы почти никогда не выглядят как «ошибка пользователя». Чаще это нормальное поведение интерфейса и сети, которое внезапно приводит к двум заказам, двум оплатам или двум одинаковым записям в базе.
Самый частый источник повторов - клиент. Кнопка «Создать» выглядит активной, экран не показывает прогресс, и человек нажимает еще раз. Или форма отправилась, но страница обновилась, приложение свернулось и вернулось, и пользователь повторил действие «на всякий случай».
Еще один вариант - навигация между экранами. Человек оформил заказ, ушел назад, снова открыл корзину и нажал «Оформить» второй раз. Внешне это логично, а для сервера это два отдельных запроса.
На плохом интернете запрос может дойти до сервера, а ответ не вернуться (таймаут, разрыв, переключение сети). Пользователь видит «не получилось» и повторяет. То же бывает из-за автоматических повторов в браузере, мобильном SDK или сетевой библиотеке: они пытаются «помочь» и отправляют запрос еще раз.
Простой сценарий: вы нажали «Оплатить», экран завис, через 10 секунд показал ошибку. На самом деле платеж ушел, но подтверждение не дошло.
Даже если UI и сеть идеальны, повторы может создать серверная часть. Очереди, воркеры и фоновые задачи часто настроены на повтор при ошибках и таймаутах. Если обработчик не умеет отличать «уже сделали» от «делаем впервые», одна и та же задача выполнится дважды.
В платежах это особенно больно: пользователь нажал «Оплатить» снова, а параллельно сработал повтор обработки в фоне. Поэтому повторы нужно считать неизбежными и проектировать систему так, будто они случатся.
Ключ идемпотентности - метка, которая говорит серверу: «это то же самое намерение пользователя, не делай операцию второй раз». Он особенно важен для действий вроде «создать заказ» или «оплатить», где повтор приводит к дублям, лишним списаниям и странным статусам.
Где «живет» ключ: чаще всего его передают в заголовке запроса (например, отдельным header), реже - в теле. Заголовок удобнее, потому что не смешивает бизнес-данные (сумма, товары) с технической защитой от повторов. Главное, чтобы клиент мог повторить запрос с тем же ключом.
Хороший ключ:
На сервере ключ не просто «проверяют и забывают». Обычно хранят запись: сам ключ, текущий статус (в обработке, успешно, ошибка), результат (например, id созданного заказа) и время создания. Это позволяет корректно ответить на повтор.
Если приходит повторный запрос с тем же ключом, сервер не должен выполнять операцию заново. Он возвращает то же самое, что уже было выдано ранее, или текущий статус, если операция еще выполняется. Пример: первый запрос ушел при плохой связи, клиент не получил ответ и отправил снова. С ключом идемпотентности сервер увидит совпадение и вернет уже созданный order_id, вместо создания дубля.
«Создать» и «оплатить» часто стоят рядом в пользовательском сценарии, но по смыслу это разные типы действий. Создание рождает новую сущность. Оплата переводит уже существующую сущность в другое состояние и может затрагивать внешние системы.
Операция «создать» должна возвращать один и тот же результат при повторах. Поэтому ключ удобно генерировать на клиенте до отправки запроса и прикладывать к нему каждый раз, пока пользователь не получит ответ.
Практичная связка идентификаторов: userId (кто создает) + clientRequestId (уникален для попытки) и, при необходимости, параметры, которые вы хотите защитить от подмены. Сервер при повторе возвращает стабильный идентификатор созданной сущности (например, orderId) и те же поля, что и в первый раз.
Оплата - это не создание, а переход заказа по статусам. Ключ здесь стоит привязать к заказу и конкретной попытке оплаты. Иначе легко получить два платежа на один заказ.
Обычно используют связку orderId + userId + clientRequestId (или paymentAttemptId, если вы заводите отдельную сущность «попытка оплаты»). Тогда повторы одного и того же запроса не порождают новую попытку.
Что возвращать клиенту при «оплатить»:
orderId и идентификатор попытки (paymentAttemptId или clientRequestId)pending, succeeded, failedТак UI может безопасно показывать один экран результата, даже если сеть моргнула, а сервер может отвечать одинаково, пока итог платежа не изменится.
Для операций «создать» и «оплатить» у UI две задачи: не дать пользователю случайно запустить действие дважды и при проблемах сети безопасно повторить запрос.
Держите один и тот же идентификатор попытки (например, clientRequestId) от первого клика до финального ответа. Тогда повторный запрос не запускает новую операцию, а «находит» предыдущую.
clientRequestId один раз: при открытии формы или при первом нажатии. Это должен быть уникальный случайный ID.Idempotency-Key: <id>). Поставьте разумный таймаут, чтобы UI не «висел» бесконечно.Пользователь может обновить вкладку или вернуться назад. Если операция критичная (например, оплата), сохраните clientRequestId локально до получения финального статуса и удалите его после успеха. Тогда после перезагрузки вы сможете повторить запрос с тем же ключом или запросить статус и показать пользователю, что уже произошло.
Пример: человек нажал «Оплатить», связь пропала, он нажал еще раз. UI отправляет повтор с тем же clientRequestId, а сервер возвращает «уже выполнено» и тот же платеж, вместо второго списания.
Чтобы сервер отличал повтор от новой операции, нужен слой хранения по ключу идемпотентности.
Часто хватает отдельной таблицы (или key-value хранилища), где фиксируется запрос и итог. Поля могут быть такими:
idempotencyKey, userId (или clientId), endpointrequestHash (хэш тела запроса, чтобы ключ нельзя было переиспользовать с другим смыслом)status (processing, completed, failed)responseCode и responseBody (или ссылка на сохраненный ответ)createdAt и expiresAtСамая важная часть - атомарность. Запись по ключу должна появляться ровно один раз, иначе две параллельные попытки успеют обе сделать «создать заказ». В PostgreSQL это обычно решается уникальным индексом на (userId, endpoint, idempotencyKey) и вставкой с обработкой конфликта. Дальше логика простая: кто первый создал запись, тот выполняет действие и сохраняет ответ; кто пришел позже, читает существующую запись.
Что возвращать при повторе:
requestHash другой, верните 409: почти всегда это ошибка клиента или попытка злоупотребленияКлючи не должны жить вечно. Выберите TTL (часы или дни, по смыслу операции), регулярно очищайте просроченные записи и ставьте лимиты на размер responseBody, иначе таблица быстро раздуется.
И еще одно: наблюдаемость. Полезно считать, сколько повторов приходит, где чаще всего processing -> completed занимает долго, и какие эндпоинты дают больше всего конфликтов по requestHash. Это быстро показывает, где реальная проблема: UI, мобильная сеть или перегрузка сервера.
С оплатами лучше не пытаться «оплатить и сразу подтвердить» одной кнопкой и одним состоянием. Надежнее считать оплату отдельной сущностью, например paymentAttempt: одна попытка списания, один ключ идемпотентности и один набор статусов. Так вы защищаетесь от двойных кликов, повторов сети и дублей вебхуков.
Минимального набора статусов обычно достаточно:
Ключевой момент: списание может «повиснуть». Таймаут, обрыв связи или ошибка 502 не означают, что денег не списали. Поэтому при неизвестном результате нельзя автоматически создавать новую попытку с теми же параметрами. Вместо этого используйте один и тот же ключ для повтора запроса к провайдеру (если провайдер поддерживает) или проверяйте статус по идентификатору попытки.
С вебхуками правило простое: любой колбэк может прийти несколько раз и в любом порядке. Обрабатывайте их идемпотентно: храните providerEventId (или хэш события), игнорируйте повтор, а переходы статусов делайте однонаправленными (нельзя из confirmed вернуться в pending).
На экране ожидания показывайте только то, что снижает тревогу и не провоцирует повторные оплаты:
В TakProsto это удобно моделировать отдельной сущностью платежа и явными статусами, чтобы повторы запросов и вебхуков не ломали финансы и не создавали дубликаты заказов.
Идемпотентность чаще всего ломается не из-за сложной логики, а из-за мелких решений «на скорую руку».
Первая ловушка - «просто заблокируем кнопку». Если кнопка стала неактивной, пользователь должен понимать, что происходит: покажите состояние «Отправляем...», не ломайте доступность (фокус с клавиатуры, озвучка для скринридеров, понятный текст статуса). Иначе люди начнут обновлять страницу, жать Enter, уходить назад, и вы сами спровоцируете повторы.
Вторая ловушка - вы отключили кнопку, но все равно получили два запроса. Это нормальный сценарий: две вкладки, два устройства, повторная отправка формы, переход по письму или пушу во втором окне. UI не может быть единственной защитой. Сервер обязан узнавать повтор.
Что чаще всего ломает защиту на практике:
Пример: у пользователя плохая связь, он нажимает «Создать заказ» и видит ошибку. Если приложение (или ваш проект в TakProsto) генерирует новый ключ при повторе, сервер создаст второй заказ. Если ключ тот же и сервер хранит результат, повторный запрос вернет тот же заказ, даже если первый ответ потерялся.
Перед релизом полезнее проверять не «идемпотентность вообще», а конкретные намерения: нажал «Создать заказ», нажал «Оплатить», вернулся назад, обновил страницу, связь пропала.
Отдельно прогоните сценарий «плохая связь»: запрос ушел, ответ не пришел, пользователь нажал еще раз. В корректной схеме это приводит к одному итоговому состоянию, даже если запрос придет дважды.
Без наблюдаемости вы узнаете о проблеме от пользователей. Добавьте метрики по доле повторов, количеству срабатываний идемпотентности и алерты на резкие всплески.
Пользователь оформляет заказ в метро. Он выбрал товары, нажал «Создать заказ» и сразу «Оплатить». В этот момент связь на секунду пропадает: запрос ушел, но ответ не пришел. На экране это выглядит как зависание, и рука сама тянется нажать «Оплатить» еще раз.
Если ничего не предусмотреть, вы получите два заказа или два списания. Это почти всегда заканчивается обращением в поддержку и недоверием к сервису.
Правильное поведение начинается на кнопке. После первого нажатия UI не запускает вторую попытку «на всякий случай», а переключается в режим ожидания и объясняет, что происходит.
Клиент отправляет один и тот же ключ идемпотентности для конкретного действия (например, для оплаты этого заказа). Сервер хранит результат по ключу и отвечает одинаково, даже если запрос пришел повторно.
На практике это выглядит так: первый запрос на оплату дошел и создал попытку списания, но ответ потерялся. Пользователь нажал снова, второй запрос прилетел с тем же ключом. API не создает новую оплату, а возвращает уже существующий заказ и текущий статус платежа (например, «в обработке» или «успешно»).
Итог простой: один заказ, одна попытка списания. А в интерфейсе вместо паники и повторных кликов пользователь видит аккуратное «Проверяем статус» и финальный результат, когда связь восстановится.
Лучший способ внедрить идемпотентность - не переделывать все сразу, а начать с самых дорогих ошибок. Обычно это операции, где повтор стоит денег или портит данные: «создать заказ», «оплатить», «оформить подписку», «списать бонусы».
Начните с 1-2 критичных операций и договоритесь о едином подходе: один ключ на одно намерение, а повтор возвращает тот же результат.
После этого не переходите к следующей операции, пока не прогоните реальные сценарии повторов. Проверяйте не только двойной клик, но и «плохую» сеть, когда пользователю кажется, что ничего не произошло.
Хороший набор тестов занимает час, но экономит дни разборов:
Если вы делаете продукт в TakProsto, полезно заложить эти сценарии с первых экранов. Поменяли API или логику кнопки - сохраните снапшот, а при странных дублях быстро сравните поведение до и после. Это превращает идемпотентность из «пожарной меры» в нормальную привычку разработки.
Если нужен быстрый ориентир по практике, в TakProsto на takprosto.ai удобно заранее проектировать критичные действия (заказ, оплата, подписка) как отдельные операции с понятными статусами и ключами идемпотентности. Это помогает не упираться в «идеальный UI», а строить защиту на уровне контракта клиента и сервера.
Повторы появляются, когда пользователь не видит подтверждения и повторяет действие: двойной клик, обновление страницы, возврат назад и повторное нажатие. Еще источник — сетевые таймауты и автоматические ретраи в клиенте или инфраструктуре, когда запрос дошел, а ответ потерялся.
Они меняют состояние системы больше одного раза: создаются дубли заказов, повторно списываются деньги, дважды отправляются письма и уведомления. Это ломает учет (склад, бонусы, лимиты), портит аналитику и почти всегда приводит к обращениям в поддержку.
Идемпотентность означает, что повтор одного и того же запроса дает тот же результат и не делает операцию второй раз. На практике это правило «один результат на одно намерение пользователя», даже если запрос улетел дважды из-за клика или сети.
UI-идемпотентность снижает вероятность повторов: блокирует кнопку, показывает прогресс и не провоцирует «нажми еще раз». API-идемпотентность делает повторы безопасными: сервер узнает повтор и возвращает прежний результат вместо создания дубля.
Потому что UI не контролирует все сценарии: две вкладки, два устройства, повторная отправка формы, автоповторы сетевой библиотеки, ретраи в очередях и воркерах. Серверная защита нужна всегда, особенно для денег и создания сущностей.
Ключ идемпотентности — это метка, которую клиент передает с запросом, чтобы сервер понял: это то же намерение, не выполняй второй раз. При повторе с тем же ключом сервер должен вернуть тот же результат или текущий статус, а не создавать новый заказ или новую оплату.
Генерируйте один уникальный ключ на одно намерение и используйте его во всех повторах до финального ответа. Новый клик «Оплатить» — новый ключ, а таймаут и повторная отправка — тот же ключ; если генерировать новый ключ на каждый ретрай, вы сами создадите дубли.
Для «создать» ключ связывает повторы с одной созданной сущностью, и сервер возвращает один и тот же orderId. Для «оплатить» важно привязать ключ к заказу и конкретной попытке оплаты, чтобы не получить два списания на один заказ, и возвращать статус попытки (pending, succeeded, failed).
Нужна запись по ключу с уникальностью и атомарностью, чтобы два параллельных запроса не успели выполнить действие оба. Обычно хранят статус и сохраненный ответ; повтору возвращают тот же код и тело, а если ключ тот же, но смысл запроса другой, отвечают конфликтом, чтобы не подменяли параметры.
Сохраняйте ключ попытки локально до финального статуса, чтобы после перезагрузки можно было повторить запрос с тем же ключом или запросить статус. Вместо «Оплатить снова» лучше показывать «Проверяем статус» и не создавать новую попытку, пока исход неизвестен.