Ключ, который каждый раз новый
Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег
miki/klyuch-kotoryy-kazhdyy-raz-novyy · v2
Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег
Medusa, оформление заказа и списание денег, февраль 2026 — по сей день.
У всякого магазина есть минута, в которую нельзя ошибиться: гость нажал «Заказать». Если запрос уйдёт дважды — на кухню приедут два одинаковых заказа, а с карты спишут дважды. Защита от этого называется идемпотентностью и выглядит просто: у попытки есть ключ, повтор с тем же ключом ничего нового не создаёт.
Medusa эту защиту сделала. Дальше — про то, как она сработала.
Приходит разработчик под ником 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 мая — закрыта. Второй раз.
Посмотрите, что здесь произошло с точки зрения покупателя. Он нажал «Оформить» — и получил не заказ, а сообщение, что его заказ уже оформляется кем-то другим. Если гипотеза бота верна, этот «кто-то» — вебхук с подтверждением его же оплаты. Защита сработала: второго заказа нет. И покупателя тоже нет — он увидел ошибку и ушёл, а заказ доделался через две минуты, когда истёк замок.
Через полгода приходит 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 здесь несущее. Провайдер не ответил — это не «не списал». Это «неизвестно». Ровно для этого случая ключ идемпотентности и придуман: повтори с тем же ключом, и провайдер узнает свою же операцию вместо того, чтобы списать второй раз.
А ключ каждый раз новый.
Автор отметил галочку «я хотел бы это починить» — и через шестнадцать минут после задачи, в 10:00, прислал #16293. Бот через пять минут нашёл одну придирку: в комментарии к коду стоит (see #16097), а номера задач в коде проекту не нужны. Автор убрал, и через двадцать минут после открытия PR стоял initial-approval.
К одной задаче сошлись три 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: повадки проекта».
Что с этим делать у себя
Гаснущая кнопка (disabled={pending}) ловит второй клик и не ловит ни обрыв связи после отправки, ни повторную отправку из истории браузера, ни вызов действия мимо формы.
- Перечислены все пути, по которым создаётся заказ, а не только форма
Ключ попытки рождается на клиенте при нажатии и переживает ретраи. Повтор с тем же ключом возвращает первый заказ — тот же номер, тот же токен.
- Повторный вызов с тем же ключом вернул тот же идентификатор и тот же токен
- Гость при этом видит свой заказ, а не сообщение об ошибке
«Посмотреть, нет ли уже такого, и если нет — вставить» это check-then-act: два одновременных запроса проходят проверку оба. Держит только уникальный индекс плюс обработка конфликта.
- Два одновременных запроса с одним ключом дали ОДИН заказ — проверено, а не предположено
Всё, что делается ПОСЛЕ создания — уведомление кухне, письмо, списание, — для повтора уже сделано. Возвращаемый результат обязан об этом говорить.
- Результат создания различает «создано» и «найдено по ключу»
- Каждое побочное действие после создания проверено на этот флаг
Два одинаковых заказа подряд — законное желание, а не ошибка. Ключ рождается при нажатии и обнуляется после успеха.
- Повторить заказ после успешного оформления можно
Связанные списки
Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный
Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода
k8sgpt отправляет поломки кластера языковой модели и обещает анонимизацию. Через три месяца пользователь спросил, где её границы, — и получил честный ответ, который держится третий год
Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам
Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда
