Ключ, который каждый раз новый
Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег
miki/klyuch-kotoryy-kazhdyy-raz-novyy · v1
Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег
Medusa, оформление заказа и списание денег, февраль — август 2026.
У всякого магазина есть минута, в которую нельзя ошибиться: гость нажал «Заказать». Если запрос уйдёт дважды — на кухню приедут два одинаковых заказа, а с карты спишут дважды. Защита от этого называется идемпотентностью и выглядит просто: у попытки есть ключ, повтор с тем же ключом ничего нового не создаёт.
Medusa эту защиту сделала. Дальше — про то, как она сработала.
Приходит разработчик под ником sricharan505 (#14757). У него не оформляется корзина: сервер отвечает конфликтом.
throw new MedusaError(MedusaError.Types.CONFLICT, "Cart is already being completed by another request")
«Корзина уже оформляется другим запросом»
Он описывает то, что видит, тремя строками, и каждая из них важна:
The cart eventually gets completed after some time (likely after the locking duration). If I retry using the same cart ID, it succeeds after waiting.
Корзина в итоге оформляется через какое-то время — видимо, когда истекает блокировка. Если повторить с тем же id корзины, получается, но надо подождать.
И, отдельной строкой:
I have seen similar issues reported earlier, but they were closed without a resolution.
Я видел похожие сообщения раньше, но их закрывали без решения.
Задачу закрывают в тот же день со статусом not planned. В тот же день заведена и её близнец — #14758, с тем же названием.
Посмотрите, что здесь произошло с точки зрения покупателя. Он нажал «Оформить». Что-то не дошло. Он нажал ещё раз — и получил не заказ, а сообщение, что его заказ уже оформляется кем-то другим.
Защита сработала: второго заказа нет. И покупателя тоже нет.
Через полгода приходит blockgroot и приносит вторую половину (#16292). Название задачи — это уже почти весь разбор:
Retrying a payment capture after a provider-side failure sends a different idempotency key
Повтор списания после сбоя на стороне провайдера отправляет другой ключ идемпотентности
Воспроизводится это не в интерфейсе, а тестом: заставить провайдера упасть один раз (будто по таймауту) и ответить успехом на второй.
Each attempt mints a fresh
Capturerow and therefore a fresh idempotency key, regardless of whether the previous attempt failed ambiguously.
Каждая попытка заводит новую запись списания и, значит, новый ключ идемпотентности — независимо от того, чем закончилась предыдущая.
Слово ambiguously здесь несущее. Провайдер не ответил — это не «не списал». Это «неизвестно». Ровно для этого случая ключ идемпотентности и придуман: повтори с тем же ключом, и провайдер узнает свою же операцию вместо того, чтобы списать второй раз.
А ключ каждый раз новый.
Защита от двойной отправки есть, и она честно работает — в двух местах, и оба раза мимо цели.
Там, где повтор безобиден — гость нажал ещё раз, потому что не понял, дошло ли, — она отказывает. Второго заказа не будет, но и первого гость не увидит: он получит ошибку и уйдёт.
Там, где повтор стоит денег — списание, провайдер не ответил, — она пропускает, потому что ключ на повторе другой.
Разница между этими двумя случаями не техническая, а смысловая: в первом повтор надо проглотить и ответить как в первый раз, во втором — узнать и не делать дважды. И то и другое требует одного: ключа, который живёт дольше одной попытки. В первом случае он есть, но ответ на него — отказ; во втором ответом был бы успех, но ключа нет.
Что с этим делать у себя
Гаснущая кнопка (disabled={pending}) ловит второй клик и не ловит ни обрыв связи после отправки, ни повторную отправку из истории браузера, ни вызов действия мимо формы.
- Перечислены все пути, по которым создаётся заказ, а не только форма
Ключ попытки рождается на клиенте при нажатии и переживает ретраи. Повтор с тем же ключом возвращает первый заказ — тот же номер, тот же токен.
- Повторный вызов с тем же ключом вернул тот же идентификатор и тот же токен
- Гость при этом видит свой заказ, а не сообщение об ошибке
«Посмотреть, нет ли уже такого, и если нет — вставить» это check-then-act: два одновременных запроса проходят проверку оба. Держит только уникальный индекс плюс обработка конфликта.
- Два одновременных запроса с одним ключом дали ОДИН заказ — проверено, а не предположено
Всё, что делается ПОСЛЕ создания — уведомление кухне, письмо, списание, — для повтора уже сделано. Возвращаемый результат обязан об этом говорить.
- Результат создания различает «создано» и «найдено по ключу»
- Каждое побочное действие после создания проверено на этот флаг
Два одинаковых заказа подряд — законное желание, а не ошибка. Ключ рождается при нажатии и обнуляется после успеха.
- Повторить заказ после успешного оформления можно
Related lists
Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный
Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода
graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет
Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении
Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда
Как Cal.com год чинил расписание, переходящее за полночь, и чем это кончилось

