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

Почему дубликаты создаются в реальной жизни
Дубликаты при создании обычно появляются не из‑за «плохих пользователей». Они случаются потому, что сети и люди — неудобные вещи.
Телефон на шатком Wi‑Fi отправляет запрос, в приложении крутится спиннер, и пользователь нажимает кнопку снова. Или запрос доходит до сервера, но ответ до клиента не доходит, и клиент повторяет запрос.
Ретраи также происходят автоматически. Мобильные приложения, библиотеки fetch в браузере, API‑шлюзы и фоновые исполнители задач часто пробуют заново после таймаута, разрыва соединения или 502/503. С точки клиента, повторный запрос — безопасный ход: "я не уверен, сработало ли, попробую ещё раз."
На практике «дублирующее создание» выглядит так:
- Два заказа для одной покупки
- Два тикета в службу поддержки по одной жалобе
- Две подписки или два списания за одно нажатие
- Два приглашения одному и тому же человеку
Самое неприятное — сервер часто не может отличить новый запрос от повторного. Если ваш POST всегда вставляет новую строку, каждый ретрай — вторая строка. Клиент может это не показать, но база и ваши клиенты увидят.
AI‑сгенерированные бэкенды особенно склонны к таким ошибкам, потому что они фокусируются на счастливом пути: клик — запрос — ответ. Они часто не моделируют таймауты, двойные тапы или гонки запросов.
Цель проста: если тот же запрос на создание повторяется, результат должен быть тем же, а не второй операцией. Именно это защищает идемпотентность API.
Идемпотентность API простыми словами
Идемпотентность API означает: если один и тот же запрос отправлен несколько раз, результат такой же, как если бы он был отправлен один раз.
Это важно, потому что многие системы по сути работают с доставкой «как минимум один раз». Запрос может прийти один или два раза, но обычно он дойдёт.
Некоторые действия по природе идемпотентны. Типичный GET можно повторять без изменений. Обновление страницы не должно создавать нового пользователя.
Другие действия по природе не идемпотентны. Обычный POST, который создаёт что‑то (заказ, пользователя, списание), при повторении может создать две записи или два списания. Если первый POST прошёл, но ответ потерялся, клиент делает ретрай и случайно создаёт дубликат.
Идемпотентность даёт безопасный способ ретраевать. Она не предотвращает все ошибки и не исправит сломанную бизнес‑логику, но предотвращает повторные действия при ретраях.
Где идемпотентность важнее всего
Она не нужна везде. Она нужна там, где ретрай может спровоцировать второе реальное действие: больше денег списано, отправлено лишних писем, запущено лишних задач, создано лишних строк.
Приоритизируйте эндпоинты, которые создают или запускают то, что тяжело отменить. Частые примеры:
- Платежи (charge, authorize, capture)
- Заказы, подписки, бронирования
- Отправка Email/SMS (сброс пароля, приглашения, квитанции)
- Фоновые задания (экспорт, обработка, генерация отчётов)
- Обработчики вебхуков, которые что‑то создают или выдают
Также следите за эндпоинтами, которые выглядят как «обновления», но ведут себя как добавления. Всё, что инкрементирует счётчики, добавляет элементы, начисляет кредиты или меняет итоги, может примениться дважды при ретрае.
Idempotency‑ключи (request IDs): базовый паттерн
Idempotency‑ключи — самый практичный способ сделать write‑запросы безопасными для ретраев.
Идея простая: клиент отправляет уникальный ID запроса вместе с write‑запросом (обычно POST), а сервер обещает, что повторение того же ключа не создаст второго ресурса и не повторит операцию.
Обычный подход — принимать заголовок вроде Idempotency-Key (или поле requestId в теле). Если клиент таймаутнул и повторяет, он использует тот же ключ.
На сервере вы храните небольшую запись на каждый ключ. Большинство команд сохраняют:
- сам ключ
- кому он принадлежит (область — scope)
- к какой операции он применяется (эндпоинт, метод)
- статус (in_progress, succeeded, failed)
- ответ, который был возвращён (код статуса и тело)
Важность области (scope)
Ключ не должен быть глобальным для всей системы. Хорошие дефолты ограничивают его одной из опций: на пользователя, на аккаунт/тенант или на API‑токен, и обычно ещё по эндпоинту.
Например, ключ, использованный для POST /orders, не должен приниматься для POST /refunds, даже если строка совпадает.
Время хранения — компромисс
Храните ключи достаточно долго, чтобы покрыть реальные ретраи (минуты‑часы), плюс запас для медленных клиентов и очередей. Некоторые команды держат их 24 часа или несколько дней, чтобы защититься от случайных перепроигрываний. Длительное хранение снижает дубликаты, но увеличивает объём хранилища и требует планов по очистке.
Поведение при повторе
Когда тот же ключ повторяется, сервер должен вернуть оригинальный результат, а не выполнять действие снова.
Пример: чек‑аут вернул 201 с order_id=123. Если клиент ретраит с тем же ключом, верните тот же 201 и тот же order_id.
Уникальные ограничения: страховочная сетка для гонок
Idempotency‑ключи помогают, но сами по себе они недостаточны. Ваша последняя линия защиты — база данных.
Когда два запроса попадают на API почти одновременно, только база надёжно остановит оба от создания одинакового объекта.
Уникальное ограничение говорит базе: "строка такого вида может быть только одна." Даже если ваш код запустит две копии запроса параллельно, база отвергнет дубликат.
Частые примеры:
- Уникальный email в таблице
users - Уникальная пара вроде
(account_id, external_id)при импорте из Stripe, QuickBooks или CRM - Уникальный
order_number
Практичный паттерн — хранить request ID в записи, которую вы создаёте. Например, добавить колонку idempotency_key в orders и сделать её уникальной, часто в связке (account_id, idempotency_key). Тогда каждый ретрай мапится на ту же строку.
Когда ограничение срабатывает, API обычно делает одно из двух:
- Возвращает существующую запись (лучше для "create order", "start checkout", "create invoice")
- Возвращает понятную ошибку конфликта (лучше, когда повторное использование ключа может скрыть реальную ошибку)
Не полагайтесь на "проверить, потом вставить" в прикладном коде. Два воркера могут одновременно проверить "существует ли?" и оба увидеть "нет" до вставки. Сделайте уникальность правилом базы.
Как добавить идемпотентность в POST‑эндпоинт (по шагам)
Если клиент таймаутнул и повторил POST, вы хотите, чтобы второй вызов вернул тот же результат, а не создал вторую запись.
Начните с двух решений:
- Какие действия при повторении наносят реальный вред (заказы, списания, приглашения, джобы)
- К чему ключ привязан (по эндпоинту + пользователю/аккаунту — надёжный дефолт)
Затем реализуйте это так, чтобы оно оставалось корректным при конкурентном доступе.
1) Требуйте стабильный request ID
Для рискованных эндпоинтов требуйте заголовок Idempotency-Key (или поле request ID). Клиенты должны переиспользовать одно и то же значение при ретраях.
2) Сохраните ключ
Либо заведите таблицу idempotency (key, endpoint, user/account, status, response), либо сохраняйте ключ прямо в создаваемой записи, если есть чистая 1:1 связь.
3) Захватите ключ атомарно
Сначала вставьте запись с ключом, защищённую уникальным ограничением (или возьмите лок). Так вы не дадите двум конкурентным запросам "выиграть" одновременно.
4) Сохраните ответ, который возвращаете
После успешной операции сохраните ответ (код статуса и тело). При повторных запросах возвращайте сохранённый ответ, не выполняя работу заново.
5) Решите, что делать, когда запрос в процессе
Если ретрай приходит, пока первый запрос ещё выполняется, нужно предсказуемое поведение. Частые варианты:
- Небольшая задержка с повторной проверкой
- Вернуть
409 Conflict(или202 Accepted) с понятным сообщением "в процессе"
Выберите один подход и придерживайтесь его.
Обработка сложных случаев: таймауты, конкуренция и частичные ошибки
Ретраи запутываются, когда клиент и сервер расходятся во мнениях о том, что произошло. Идемпотентность делает эти моменты скучными: тот же запрос — тот же результат.
Таймаут после успеха
Сервер создал запись, но ответ не дошёл. Без защиты ретрай создаст вторую запись.
Относитесь к idempotency‑ключу как к квите: если ретрай использует тот же ключ, верните оригинальный результат.
Краш в середине и конкурентные ретраи
Хранение ключа недостаточно, если вы не фиксируете, что именно произошло.
Практичные правила:
- Храните статус: pending, succeeded, failed.
- Гарантируйте, что только один запрос может владеть ключом (уникальное ограничение — самый простой способ).
- Если ретрай находит статус pending, не запускайте второй прогон. Подождите чуть‑чуть или верните понятный ответ «в процессе».
- После успеха всегда возвращайте тот же ответ для того же ключа, пока он хранится.
Частичные ошибки
Это самый тяжёлый случай. Вы могли создать пользователя, но не отправить welcome‑email, или списали платёж, но не создали строку заказа.
Выберите ясный "источник правды" для запроса и не давайте ретраям повторять побочные эффекты, которые уже произошли. Часто это означает:
- завершать оставшиеся шаги асинхронно
- использовать компенсационные шаги (например, делать рефанд, если заказ не удалось создать)
Частые ошибки, которые всё равно допускают дубликаты
Большинство дублирующих созданий происходят не потому, что идемпотентность совсем не внедрена, а потому, что её добавили наполовину.
Типичные провалы:
- Ключ идемпотентности опционален, и защищены только часть запросов.
- Сохраняют ключ, но не сохраняют результат, и ретрай всё равно выполняет операцию.
- Не ограничивают область ключей (ключ может пересекаться между пользователями или эндпоинтами).
- Делают "проверить, потом вставить" без уникального ограничения в БД.
- Считают побочные эффекты типа "отправить email" идемпотентными, не отслеживая, была ли отправка.
Классический сценарий: POST /orders сначала списывает карту, затем падает до возврата ответа. При ретрае списание происходит снова и вы получаете двойное списание. Избегайте этого, сохраняя результат и защищая его уникальностью.
Быстрый чек‑лист для проверки поведения при ретраях
API, безопасное для ретраев, должно вести себя одинаково при повторении одного и того же действия.
Для write‑эндпоинтов (POST, а иногда PATCH/DELETE):
- Требуйте
Idempotency-Keyдля операций, которые создают или запускают что‑то. - Внедрите уникальное ограничение в БД для правила дедупа.
- Убедитесь, что ретраи возвращают тот же ID ресурса и то же тело ответа (или стабильное представление).
- Определите область ключа (по пользователю/аккаунту + по эндпоинту — хороший базовый вариант).
- Храните ключи достаточно долго для реальных ретраев и логируйте контекст для дебага.
Для обработчиков вебхуков:
- Используйте ID события провайдера как idempotency‑ключ, сохраните его и возвращайте успех при повторе.
Простой тест — отправить точно тот же запрос пять раз быстро (включая тот же ключ). Вы должны увидеть одно создание и четыре ответа «тот же результат».
Пример: предотвращение двойного списания в ненадёжном чек‑ауте
Один основатель тестирует чек‑аут, который был сгенерирован как прототип. В демо он работает, но в реальной эксплуатации сеть шатается, и UI иногда зависает на спиннере.
Клиент нажал «Pay» один раз. Ничего не произошло. Они нажали ещё раз. Оба запроса добрали до API.
Без идемпотентности бэкенд трактует это как две отдельные покупки. В итоге вы можете получить два успешных платежа, две строки заказа и тикет в поддержку: «я нажал один раз». Клиент может даже открыть спор, потому что не понимает, какое списание верное.
С idempotency‑ключом и уникальным ограничением в БД второй запрос ничего не создаёт. Сервер распознаёт повтор и возвращает тот же результат, что и первый вызов: исходный order ID и статус платежа.
Как это обычно выглядит:
- Клиент генерирует один idempotency‑ключ при нажатии «Pay»
POST /checkoutсохраняет этот ключ с попыткой создания заказа- БД обеспечивает уникальность на
(user_id, idempotency_key)или(merchant_id, idempotency_key) - При ретрае API получает существующую запись и возвращает её
Поддержке проще, если логгировать несколько полей: idempotency key, итоговый order ID, ID списания у платежного провайдера, метки времени и флаг, был ли запрос повтора.
Следующие шаги для AI‑сгенерированных API, которые ломаются при ретраях
AI‑сгенерированные бэкенды часто выглядят хорошо в демо, а затем ломаются, когда пользователи обновляют страницу, мобильные сети падают или провайдеры повторяют вебхуки. Стабилизируйте эндпоинты, которые могут навредить, если выполнены дважды.
Выберите ваши три самых рискованных операции (обычно платежи, заказы, приглашения и входящие вебхуки). Добавьте два уровня защиты:
- Idempotency‑ключи, чтобы ретраи были безопасны целенаправленно
- Уникальные ограничения в базе, чтобы поймать гонки
Затем выполните небольшой реалистичный тест: дважды кликните submit, симулируйте таймаут клиента и ретрай с тем же request ID, и отправьте два конкурентных запроса с одинаковым телом. Вы хотите один реальный create и согласованные ответы «тот же результат».
Если вы унаследовали AI‑сгенерированную кодовую базу и дубликаты уже происходят, обычно быстрее сфокусировано исправить, чем переписывать всё. Если хотите второе мнение, FixMyMess (fixmymess.ai) специализируется на диагностике и исправлении AI‑сгенерированных бэкендов, включая добавление идемпотентности, защит по уникальности и продакшен‑поведения при ретраях.
Часто задаваемые вопросы
Why am I seeing duplicate records even when users swear they clicked once?
Потому что сети и приложения повторяют запросы. Запрос мог выполниться на сервере, но ответ потерялся, или пользователь дважды нажал кнопку, пока UI зависал. Если ваш POST всегда вставляет новую строку, каждый ретрай может создать ещё одну запись.
What does “API idempotency” mean in plain terms?
Идемпотентность означает, что повторение того же запроса даёт тот же результат, что и один запрос. Для операций создания это обычно означает возвращение того же ресурса при повторном запросе вместо создания второго.
Which endpoints should I make idempotent first?
Применяйте её там, где ретрай может вызвать реальный побочный эффект, который нежелательно выполнять дважды: списание денег, создание заказа, отправка приглашения, запуск долгой задачи. Для чтения обычно не нужно, а для безвредных апдейтов — часто тоже нет.
What is an idempotency key and how is it used?
Idempotency-ключ — это ID запроса, который клиент генерирует и отправляет вместе с записью (часто в заголовке Idempotency-Key). Сервер сохраняет этот ключ и при повторном появлении того же ключа возвращает оригинальный результат вместо повторного выполнения операции.
How should I scope idempotency keys so they don’t collide?
Привязывайте ключ к субъекту и операции. Хороший дефолт — по аккаунту или пользователю + эндпоинт/метод, чтобы одна и та же строка не применялась случайно к другому пользователю или другой операции, например к возвратам.
How long should I store idempotency keys?
Храните их достаточно долго, чтобы покрыть реальные ретраи и запоздалые повторные воспроизведения — обычно от нескольких часов до суток. Большее время хранения уменьшает риск дубликатов, но требует больше места и политики очистки.
What should the server return when it receives the same idempotency key again?
Верните оригинальный ответ для этого ключа, включая тот же статус-код и тело, и не повторяйте побочный эффект. Так ретраи становятся безопасными и предсказуемыми, особенно при таймаутах или потерянных ответах.
Why do I still need a database unique constraint if I have idempotency keys?
Ограничение уникальности в базе — финальная защита от гонок. Два одновременных запроса могут пройти проверку «есть ли запись?» и обе вставки попытаются выполниться; только база с уникальным ограничением гарантированно позволит получить только одну строку и выбросит конфликт для второго.
What happens if a retry arrives while the first request is still running?
Нужно правило для «в ожидании», чтобы не запустить операцию дважды. Частые подходы: подождать коротко и перепроверить, или вернуть ясный ответ «в процессе» (например, 409 Conflict или 202 Accepted), а затем обеспечить, чтобы финальный сохранённый результат был тем, что вернётся при ретраях.
How can I quickly test that my API is safe to retry?
Отправьте точно тот же запрос несколько раз с тем же idempotency-ключом и убедитесь, что создаётся одна запись, а последующие ответы возвращают тот же результат. Если вы унаследовали AI-сгенерированный бэкенд, быстрее всего локально исправить один эндпоинт целиком; FixMyMess может провести аудит рискованных эндпоинтов и добавить idempotency и ограничения в БД.