Перейти к содержимому

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

v2 0 звёзд 0 форков 0 наблюдателей 1 ветка 0 прогонов Публичный
patched via APIv2

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

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

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

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

Приходит разработчик под ником sricharan505 (#14757, 11:22). У него не оформляется корзина: сервер отвечает конфликтом — и в самом тексте ошибки уже звучит слово, вокруг которого вся эта история:

The request conflicted with another request. You may retry the request with the provided Idempotency-Key.

Запрос конфликтует с другим запросом. Можете повторить его с выданным ключом идемпотентности.

В коде это место выглядит так:

throw new MedusaError(MedusaError.Types.CONFLICT, "Cart is already being completed by another request")

«Корзина уже оформляется другим запросом»

Только другого запроса нет. В таблице workflow_execution состояние — not_started: оформление не начиналось. Дальше — три строки, и каждая важна:

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.

Я видел похожие сообщения раньше, но их закрывали без решения.

Через десять минут, в 11:32, задачу закрывает сам автор, а в 11:36 заводит заново с тем же названием — #14758. Разница в приложенном package.json: из второй версии вычищено всё, что указывало на заказчика. Это не история про проект — это про то, как легко в отчёт об ошибке попадает лишнее.

Дальше с #14758 происходит то, что автор и предсказал. 16 марта бот помечает её Stale («open 30 days with no activity»), 21-го закрывает. 23 марта Nicolas Gorga (NicolasGorga) открывает её заново — в тот же день, когда поднимает порог Stale с 30 до 60 дней. 15 апреля, в день, когда в репозитории включили триаж задач через Claude, medusa-os-bot даёт разбор: completeCartWorkflow берёт распределённую блокировку на id корзины, 30 секунд ожидания, две минуты TTL — «which matches your reported ~2-minute wait»; гипотеза — вебхук платёжного провайдера прилетает одновременно с запросом пользователя, один берёт замок, второй ждёт 30 секунд и падает, а транзакция остаётся в незавершённом состоянии. И три вопроса автору: оформился ли заказ на самом деле, какой движок workflow, совпадает ли вебхук по времени с нажатием. Ответа нет. 6 мая — снова Stale, 20 мая — закрыта. Второй раз.

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

Кадр второй: 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 здесь несущее. Провайдер не ответил — это не «не списал». Это «неизвестно». Ровно для этого случая ключ идемпотентности и придуман: повтори с тем же ключом, и провайдер узнает свою же операцию вместо того, чтобы списать второй раз.

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

Автор отметил галочку «я хотел бы это починить» — и через шестнадцать минут после задачи, в 10:00, прислал #16293. Бот через пять минут нашёл одну придирку: в комментарии к коду стоит (see #16097), а номера задач в коде проекту не нужны. Автор убрал, и через двадцать минут после открытия PR стоял initial-approval.

Кадр третий: август 2026. Три починки и ни одного ревью

К одной задаче сошлись три PR от трёх людей:

  • #16293, blockgroot, 3 августа, +156/−10.
  • #16390, Sun Kim (sunYtokki), 10 августа, +282/−16 — продолжение первого. В #16293 автор так и написал: «it keeps this PR's mechanism and both of its tests (with commit co-attribution), and extends it to cover a failure class where key reuse can't converge». Случай такой: провайдер привязывает к ключу первый ответ, включая ошибку, и при повторе с тем же ключом отдаёт её снова — платёж становится невозможно списать никогда. Поэтому ключ повторяют один раз, а после неудачного повтора меняют: [K, K, K']. PR написан от «мы», проверялся против двойника Stripe и ссылается на него как на продукт.
  • #16433, BAGHDAD Mohamed (MohamedMBG), 13 августа, +369/−56. Бот предупредил: «Heads up: PR #16293 … and PR #16390 … are both earlier open PRs that reference issue #16292… If either is merged first, this PR may be closed as a duplicate». Автор сам спросил команду, нужен ли он: «could the team confirm whether #16433 should continue as an alternative implementation».

На 02.09.2026 все три открыты, у всех initial-approval, у двух последних Stale. Ни одного ревью от человека, ни одного вливания. Задача #16292 открыта, с меткой requires-team.

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

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

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

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

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

Кто в кадре
  • sricharan505, в профиле — sricharan. Завёл #14757, сам закрыл через десять минут и завёл #14758 с тем же названием; на три вопроса бота не ответил.
  • NicolasGorga — Nicolas Gorga, MedusaJS. Открыл #14758 заново 23 марта — в тот день, когда поднимал порог Stale с 30 до 60 дней.
  • blockgroot, без имени в профиле. Задача #16292 и PR #16293 к ней шестнадцать минут спустя.
  • sunYtokki — Sun Kim. #16390: ключ повторяют один раз, потом меняют.
  • MohamedMBG — BAGHDAD Mohamed. #16433, третий PR к той же задаче; сам спросил, нужен ли он.
  • medusa-os-bot — не человек: Claude из GitHub Actions. Разобрал #14758 за команду, одобрил все три PR, предупредил о дубликате.

Подробнее о проекте — в карточке «Medusa: повадки проекта».

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

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

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

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

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

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

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

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

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

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

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

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

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

обновлён 2 сент. 2026 г.

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

обновлён 1 сент. 2026 г.

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

обновлён 3 сент. 2026 г.

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

обновлён 2 сент. 2026 г.

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

обновлён 2 сент. 2026 г.

Saleor: товар сняли с продажи — и строку из корзины покупателя стирали в фоне. Три с половиной года до теста, поменявшего знак

обновлён 2 сент. 2026 г.