Skip to content

Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1

Medusa, оформление заказа и списание денег, февраль — август 2026.

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

Medusa эту защиту сделала. Дальше — про то, как она сработала.

Кадр первый: 13 февраля 2026. «Заказ уже оформляется»

Приходит разработчик под ником 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, с тем же названием.

Посмотрите, что здесь произошло с точки зрения покупателя. Он нажал «Оформить». Что-то не дошло. Он нажал ещё раз — и получил не заказ, а сообщение, что его заказ уже оформляется кем-то другим.

Защита сработала: второго заказа нет. И покупателя тоже нет.

Кадр второй: 3 августа 2026. Ключ, который каждый раз новый

Через полгода приходит blockgroot и приносит вторую половину (#16292). Название задачи — это уже почти весь разбор:

Retrying a payment capture after a provider-side failure sends a different idempotency key

Повтор списания после сбоя на стороне провайдера отправляет другой ключ идемпотентности

Воспроизводится это не в интерфейсе, а тестом: заставить провайдера упасть один раз (будто по таймауту) и ответить успехом на второй.

Each attempt mints a fresh Capture row and therefore a fresh idempotency key, regardless of whether the previous attempt failed ambiguously.

Каждая попытка заводит новую запись списания и, значит, новый ключ идемпотентности — независимо от того, чем закончилась предыдущая.

Слово ambiguously здесь несущее. Провайдер не ответил — это не «не списал». Это «неизвестно». Ровно для этого случая ключ идемпотентности и придуман: повтори с тем же ключом, и провайдер узнает свою же операцию вместо того, чтобы списать второй раз.

А ключ каждый раз новый.

Что тут произошло

Защита от двойной отправки есть, и она честно работает — в двух местах, и оба раза мимо цели.

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

Там, где повтор стоит денег — списание, провайдер не ответил, — она пропускает, потому что ключ на повторе другой.

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

Что с этим делать у себя

1
Проверить, что защищает создание заказа КРОМЕ кнопки

Гаснущая кнопка (disabled={pending}) ловит второй клик и не ловит ни обрыв связи после отправки, ни повторную отправку из истории браузера, ни вызов действия мимо формы.

Why: Клиентская защита защищает от одного сценария из четырẻх и создаёт ощущение, что вопрос закрыт.
Check
  • Перечислены все пути, по которым создаётся заказ, а не только форма
2
Отвечать на повтор ТЕМ ЖЕ результатом, а не ошибкой

Ключ попытки рождается на клиенте при нажатии и переживает ретраи. Повтор с тем же ключом возвращает первый заказ — тот же номер, тот же токен.

Why: Повтор — не нарушение, а норма сети. Отказ защищает кухню от второй порции и наказывает гостя, который ни в чẻм не виноват.
Check
  • Повторный вызов с тем же ключом вернул тот же идентификатор и тот же токен
  • Гость при этом видит свой заказ, а не сообщение об ошибке
3
Держать гонку индексом, а не проверкой перед вставкой

«Посмотреть, нет ли уже такого, и если нет — вставить» это check-then-act: два одновременных запроса проходят проверку оба. Держит только уникальный индекс плюс обработка конфликта.

Why: Двойной клик даёт именно одновременные запросы, а не последовательные. Именно на нём проверка и проваливается.
Check
  • Два одновременных запроса с одним ключом дали ОДИН заказ — проверено, а не предположено
4
Проверить, что повтор не шлёт второе уведомление

Всё, что делается ПОСЛЕ создания — уведомление кухне, письмо, списание, — для повтора уже сделано. Возвращаемый результат обязан об этом говорить.

Why: Иначе дубль не исчезает, а переезжает из базы в канал: одна шаверма, две бумажки на кухне.
Check
  • Результат создания различает «создано» и «найдено по ключу»
  • Каждое побочное действие после создания проверено на этот флаг
5
Не выводить ключ из содержимого заказаRecommended

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

Why: Ключ по содержимому запретил бы гостю повторить тот же заказ через час — то есть защищал бы от покупки.
Check
  • Повторить заказ после успешного оформления можно

Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный

v1Public 0 0
updated Sep 1, 2026

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

v6Public 0 0
updated Sep 1, 2026

graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет

v2Public 0 0
updated Sep 1, 2026

Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении

v1Public 0 0
updated Sep 1, 2026

Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда

v1Public 0 0
updated Sep 1, 2026

Как Cal.com год чинил расписание, переходящее за полночь, и чем это кончилось

v2Public 0 0
updated Sep 1, 2026