Идемпотентность и ошибки
Создание заказа стоит денег, а сеть ненадёжна всегда. Эта страница — контракт, который делает повтор безопасным, и карта всего, что может прийти вместо успеха.
Почему ключ обязателен
POST /b2b/orders требует заголовок X-Idempotency-Key. Запрос без него отбивается с 400 и текстом "X-Idempotency-Key header is required": без списания, без заказа, без расчёта цены, ничего не резервируется.
Эта проверка — первое, что делает хендлер, а значит, она идёт после двух вещей, которые выполняются до любого хендлера: аутентификации вашего ключа и валидации формы тела фреймворком. Поэтому вызов без заголовка и без ключа получит 401, а вызов с телом, не соответствующим схеме, — 422; 400 про отсутствующий заголовок вы увидите, когда всё остальное в запросе корректно. Проверяйте эти три случая по отдельности, а не считайте, что их покрывает один статус.
Это сделано намеренно. Отказ, от которого защищает ключ, совсем не экзотический: ваш запрос дошёл, мы списали деньги и создали заказ, а ответ потерялся на обратном пути. Без ключа у вас два варианта — повторить (и заплатить дважды) или не повторять (и потерять уже оплаченный заказ). С ключом повтор вернёт исходный результат.
Заводите один ключ на одно бизнес-намерение — на один чекаут покупателя — и сохраняйте его рядом со своим заказом до вызова. Ключ, сгенерированный внутри цикла повторов, идемпотентным ключом не является.
POST /b2b/orders X-Api-Key: gc_live_… X-Idempotency-Key: 7f3c1a9e-2b44-4d1a-9d4e-1c0f9a2b3c4d
Подойдёт любая непрозрачная строка; UUID v4 — очевидный выбор. Ключи действуют в пределах вашего аккаунта, поэтому столкнуться с ключом другого партнёра невозможно.
Что считается «тем же запросом»
Решение о повторе принимает не только ключ. Мы ещё считаем отпечаток запроса, и он у́же, чем всё тело.
Отпечаток строится по items и только по ним — productId, quantity и deliveryData, нормализованные: ключи схемы отсортированы, значения обрезаны по краям, а username приведён к нижнему регистру со снятой ведущей @, поэтому @Player1 и player1 — это один заказ, а не два. Затем позиции сортируются по productId, а внутри одного товара — по нормализованному deliveryData, так что перестановка корзины отпечаток не меняет.
Один краевой случай эту сортировку переживает. Две строки одного и того же товара с одинаковым deliveryData и разным quantity сравниваются как равные — quantity попадает в хэш, но не участвует в упорядочивании, — поэтому их взаимный порядок остаётся таким, каким вы его прислали, и та же корзина с этими двумя строками, переставленными местами, даст другой отпечаток и, значит, 409 вместо повтора. Сливайте такие строки в одну с суммарным quantity — они и означают ровно это, — и краевой случай станет невозможен.
callbackUrl и externalOrderId намеренно вне отпечатка. Поэтому повтор с тем же ключом, теми же позициями, но другим адресом колбэка, который проходит валидацию, — это не конфликт, а повтор: вы получите исходный заказ, а новый адрес колбэка на него не подействует. Исправляйте адрес колбэка до того, как заказ создан, а не после.
Оговорка про валидацию здесь несущая: callbackUrl проверяется до того, как мы ищем ваш ключ. Подставленный адрес, не прошедший проверку — неразбираемый URL, схема не http(s), логин-пароль внутри адреса или хост, к которому мы отказываемся ходить (приватные, loopback- и link-local-литералы, любой IPv6-литерал, внутренние имена), — получает 400 Invalid callbackUrl: … и до поиска по ключу вообще не доходит. То есть плохой адрес не воспроизводит исходный заказ и не даёт конфликта: он просто отклонён, а ключ остаётся ровно в том состоянии, в каком был.
Сколько именно мы помним ключ, стоит сказать точно, потому что «24 часа» — это только половина ответа. При первом успешном использовании пишутся две строки — одна хранит ответ, вторая привязывает ваш ключ к позициям, с которыми он был использован, — и обе помечены сроком в 24 часа. Неудавшаяся попытка не пишет ни одной, почему — в следующем разделе. Уборка идёт раз в час и удаляет их после истечения срока. В промежутке между истечением и уборкой строки на месте и всё ещё решают: повтор по-прежнему вернёт сохранённый ответ, а несовпавшая корзина по-прежнему даст конфликт.
То есть ключ помнится минимум 24 часа и максимум примерно на час дольше, и предсказать, где именно в этом коридоре окажется конкретный ключ, нельзя. Только когда строки действительно удалены, тот же ключ с теми же позициями создаст второй, уже по-настоящему новый заказ — со вторым списанием. Очереди повторов, способной пережить сутки, нужен собственный предохранитель от дублей: наш после этого момента им не является.
Повтор, конфликт и «в полёте»
| Что вы отправили | Что получите |
|---|---|
| Тот же ключ, те же позиции, после успешного первого запроса | 200 с исходным телом ответа, байт в байт. Ни второго заказа, ни второго списания. |
| Тот же ключ, те же позиции, пока первый запрос ещё выполняется | Ваш вызов ждёт наш. Когда первый завершится, вы получите его сохранённый 200 — а если он всё-таки упал, ваш вызов пойдёт дальше как первая попытка. Опрашивать нас не нужно. |
| Тот же ключ, другие позиции, после успешного первого запроса | 409, idempotency_key_reused_with_different_body: … |
| Тот же ключ после неудавшейся попытки | Обычная новая попытка — с теми же позициями или с другими. Ничего не воспроизводится, потому что ничего не сохранилось. |
Последняя строка удивляет чаще всего — и именно на ней держится политика повторов ниже.
Обе строки идемпотентности пишутся внутри той же транзакции базы, что создаёт платёж и заказы, а строка с ответом заполняется настоящим 200 до коммита этой транзакции. Промежуточного состояния, которое можно было бы увидеть, попросту нет. Если что-то в транзакции падает — не хватило баланса, товар отклонён, сбой на нашей стороне, — вся транзакция откатывается и уносит с собой обе строки. От попытки не остаётся следа, и ключ снова свободен.
Отсюда два следствия, которые стоит сказать прямо. 402 Insufficient balance повторяется тем же ключом ровно потому, что ключ не был израсходован. А конфликт 409 возникает только после успешного использования: другая корзина под ключом, чья первая попытка упала, — это не конфликт, а новый заказ.
409 Duplicate request in progress, retry later — оборонительный ответ для состояния, которого штатный путь не производит; скорее всего, вы его никогда не увидите. Если увидели, считайте его временным: подождите секунду-другую и повторите тот же самый запрос.
А вот idempotency_key_reused_with_different_body повтором не лечится. Он будет отдавать 409 ровно столько, сколько хранится привязка ключа к позициям, — минимум 24 часа с первого, успешного использования, — а когда эта строка будет убрана, тот же вызов сделает кое-что похуже отказа: по-настоящему купит новую корзину под ключом, который вы считали израсходованным. Он означает, что ваш код изменил корзину под уже использованным ключом: считайте это багом в собственном состоянии заказа, заводите новый ключ для по-настоящему нового заказа, а старый ключ используйте только для повтора идентичных позиций.
Конвертов два, а не один
Эта деталь подводит первой, поэтому ветвитесь по HTTP-статусу, а не по полю:
{ "error": "Unauthorized", "message": "Invalid or missing API key" }
Отказы аутентификации и доступа приходят в конверте { error, message } и без поля success: это 401 при отсутствующем или непригодном API-ключе и 403, когда аккаунт не оптовый либо область видимости ключа не покрывает эндпоинт. Клиент, который проверяет response.success, увидит undefined ровно на тех двух отказах, с которыми новая интеграция сталкивается раньше всего.
Один 403 из этой семьи выпадает. Проверка Origin (защита от CSRF) срабатывает только на пишущем запросе с заголовком Origin, которого нет в списке разрешённых, — то есть вы встретите её, когда вызовы идут из браузера, а не с вашего бэкенда, — и отвечает { "success": false, "error": "Forbidden: Origin not allowed" }. Два 403 — две формы: ещё одна причина ветвиться по статусу и считать оба поля необязательными.
{ "success": false, "error": "too_many_items: maximum 200 items per order" }
Бизнес-отказы приходят как { success: false, error }, где error — читаемая человеком строка. Часть строк начинается с машиночитаемого токена перед двоеточием (too_many_items:, region_not_allowed:, ZERO_PRICE_ITEM:, idempotency_key_reused_with_different_body:); сопоставляйте именно этот префикс, а не всё предложение — формулировку могут переписать.
По краям есть ещё две формы. Тело, нарушающее схему запроса — число без кавычек в deliveryData, отсутствующий items, — отбивается собственной валидацией фреймворка с кодом 422 и конвертом валидации, не похожим ни на один из двух выше. А внутренний сбой отдаёт намеренно бессодержательный 500: внутренности мы в тело ошибки не выносим.
Таблица ошибок
| HTTP | Когда | Пример error |
|---|---|---|
| 400 | Нет заголовка идемпотентности | X-Idempotency-Key header is required |
| 400 | Пустая или слишком большая корзина | Items list is empty · too_many_items: maximum 200 items per order |
| 400 | Плохой callbackUrl | Invalid callbackUrl: … |
| 400 | Товар неизвестен, неактивен или невидим для вас | Product 34521 not found or unavailable · Product 34521 is not available for this site |
| 400 | Товару нужна загрузка файла | Product 34521 requires an image upload, which is not supported via the B2B API |
| 400 | Данные доставки не проходят схему товара или форматное правило | сообщение по конкретному полю |
| 400 | Регион недоступен вашему аккаунту | region_not_allowed: product 34521 is not available in your site's allowed regions |
| 400 | У товара нет пригодной цены | ZERO_PRICE_ITEM: product 34521 has a non-positive base price |
| 400 | Количество вне диапазона или не целое | Invalid quantity for product 34521 |
| 401 | Ключ отсутствует, неизвестен, истёк или заблокирован по домену | Invalid or missing API key |
| 402 | Баланс или кредитный лимит | Insufficient balance · Exceeds credit limit (available: …) |
| 402 | Баланс изменился, пока этот заказ коммитился (обычно ваш же параллельный вызов) | Concurrent balance modification |
| 403 | Аккаунт не оптовый либо не та область видимости ключа | This endpoint is only available for B2B API partners |
| 403 | Пишущий запрос с недопустимым Origin | Forbidden: Origin not allowed |
| 404 | Неизвестный код заказа, slug игры или id товара | Order not found · Game not found · Product not found |
| 409 | Конфликт идемпотентности или дубль в полёте | см. таблицу выше |
| 422 | Тело запроса не соответствует схеме | конверт валидации фреймворка |
| 429 | Превышен лимит запросов | Too many requests. Please try again later. |
| 503 | Правило доставки не удалось вычислить | сообщение правила |
| 500 | Внутренний сбой | Internal server error |
402 покрывает две разные проблемы, и разница определяет ваши действия. Insufficient balance и Exceeds credit limit означают, что денег нет: пополните баланс и повторите тот же самый вызов с тем же ключом. Concurrent balance modification означает, что деньги были, но ваш баланс изменило что-то ещё — обычно другой ваш же параллельный вызов создания заказа, — пока этот коммитился, и вся транзакция откатилась, поэтому списания нет и заказа нет. Повторите тот же самый запрос, без изменений и с тем же ключом, после короткой паузы. Пополнение здесь не помогает и не требуется. Если создавать заказы по аккаунту последовательно (или хотя бы не пускать их пачкой в одну секунду), такой ответ становится редкостью.
Лимиты запросов
Бюджет по умолчанию — 500 запросов в минуту, и считается он по IP клиента, а не по ключу: несколько серверов за одним NAT делят одну корзину, а один сервер с двумя ключами двух корзин не получает.
Каждый ответ, который доходит до лимитера, несёт X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset (секунды до обновления окна), так что приближение к потолку видно до того, как вы в него упрётесь. Один ответ до лимитера не доходит: проверка Origin, описанная выше, стоит раньше него, поэтому её 403 Forbidden: Origin not allowed приходит вообще без этих заголовков. Сначала смотрите на статус, потом на бюджет. При превышении:
{"success": false,"code": "RATE_LIMITED","error": "Too many requests. Please try again later.","retryAfter": 37}
и заголовок Retry-After с тем же числом секунд. Соблюдайте его: повтор раньше срока просто сжигает бюджет, которого у вас нет. Если нагрузка честно требует большего — ночной обход каталога, разовый бэкфилл при миграции, — попросите нас поднять лимит, а не размазывайте вызовы по IP-адресам.
Рабочая политика повторов
| Ответ | Повторять? | Как |
|---|---|---|
| Сетевая ошибка, таймаут, ответа нет | Да | Тот же ключ, то же тело. Ради этого ключ и существует. |
429 | Да | После Retry-After, затем экспоненциальный бэкофф с джиттером. |
503 | Да | С бэкоффом. Не выкидывайте поле, которое проверяло правило. |
500 | Да, раз-другой | С бэкоффом. Если повторяется — алерт, а не цикл. |
402 Insufficient balance · Exceeds credit limit | Да, после пополнения | Тем же ключом. Списания не было, заказ не создан. |
402 Concurrent balance modification | Да, сразу | Тем же ключом и телом, после короткой паузы. Это временная конкуренция, а не проблема с деньгами, — пополнять не нужно. |
409 Duplicate request in progress | Да | После короткой паузы, тем же запросом. Редкость: штатный путь заставляет вас подождать, а не отвечает так. |
409 …reused_with_different_body | Нет | Чините своё состояние. Пока привязка хранится, успеха не будет, а когда она истечёт, повтор по-настоящему купит новую корзину. |
400, 403, 404, 422 | Нет | Детерминированы. Повтор ничего не изменит. |
Ограничьте общее число попыток и сдавайтесь в алерт, а не в бесконечный цикл, — но никогда не сдавайтесь через изменение запроса: изменённая корзина под тем же ключом превращает поправимый таймаут в вечный 409.
Перед запуском
- Идемпотентный ключ создаётся и сохраняется до вызова и переиспользуется без изменений во всех повторах этого чекаута.
- Повторы никогда не меняют корзину.
- Клиент ветвится по HTTP-статусу, а
successсчитается необязательным полем. - Сопоставление ошибок идёт по префиксу
code:, где он есть, а не по всей фразе. 409разделён на два случая, и повторяется только один из них.402тоже разделён: проблема с балансом ждёт пополнения, аConcurrent balance modificationпросто повторяется.X-RateLimit-Remainingвыведен на дашборд, аRetry-Afterсоблюдается — и дашборд не ломается на единственном ответе, где нет ни того, ни другого.- У очереди повторов, способной пережить 24 часа, есть собственный предохранитель от дублей — нашу запись убирают в течение примерно часа после истечения срока.
Нужен ключ API?
Напишите, какие игры и регионы вы продаёте, — выдадим ключ и пройдём интеграцию вместе с вами. На вопросы по интеграции отвечают те же люди, которые ведут API.