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

0066. Сверка token-bucket со счётчиком WB по заголовкам X-Ratelimit-*

Копия ADR из репозитория: backend/docs/adr/0066-wb-rate-limit-header-reconciliation.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-10-01
  • Связанные задачи: OP-225; уточняет ADR-0048 (token-bucket), ADR-0051, ADR-0057

Контекст и проблема​

Локальный лимитер WB (WbRateLimiter, token-bucket в Redis) — это модель лимита, собранная из чисел, выписанных руками в wb-rate-limits.ts. Модель расходится с реальным счётчиком WB системно, и причины не устранимы локально:

  • мы считаем момент отправки, WB — момент приёма;
  • наш ключ — (accountId, scope, path), а лимит WB объявлен «на аккаунт продавца»: несколько наших корзин могут делить один бюджет;
  • ключ корзины живёт в Redis и может быть вытеснен;
  • тем же токеном продавца может пользоваться не только наш сервис.

Единственной обратной связью о реальном состоянии счётчика был 429 (ADR-0048, penalize) — то есть сигнал задним числом, уже после отказа.

При этом WB отдаёт своё состояние на каждом ответе:

ЗаголовокЗначение
X-Ratelimit-Limitмаксимальный всплеск, который восстановится
X-Ratelimit-Remainingсколько запросов осталось в текущем окне
X-Ratelimit-Resetчерез сколько секунд лимит восстановится полностью
X-Ratelimit-Retryчерез сколько секунд можно повторить (приходит с 429)

Наблюдения с прода показали и вторую сторону расхождения: WB отдаёт Limit: 300 там, где в реестре стоит statistics: 60/мин — то есть местами мы душим себя впятеро сильнее необходимого.

Рассмотренные варианты​

  1. Оставить как есть, полагаясь на penalize после 429. Плюс: ноль работы. Минус: каждый выход за лимит стоит реального отказа WB и попытки ретрая, а на 1/мин-бакетах — минут ожидания.
  2. Синхронизировать корзину с Remaining в обе стороны (и сужать, и расширять). Плюс: автоматическая калибровка, бюджет не простаивает. Минус: гранулярность не совпадает — при нескольких корзинах под одним бюджетом WB подъём до Remaining в каждой из них выпускает кратно больше запросов, чем разрешено. Плюс молча отменяет сознательно консервативные числа реестра.
  3. Сверять только вниз (принято). Корзина никогда не становится разрешающей больше, чем была; расширение лимитов остаётся ручной правкой реестра с диффом.
  4. Отказаться от локального лимитера в пользу заголовков. Минус: заголовки приходят после запроса, а лимитер нужен до; часть эндпоинтов их не отдаёт вовсе.

Решение​

WbApiClient на каждом ответе (любого статуса) читает заголовки (parseObservedRateLimit) и вызывает WbRateLimiter.reconcile.

Только сужение. tokens = min(остаток_корзины, Remaining); Remaining выше нашего остатка игнорируется.

Исчерпанный бюджет держится до сброса окна. При Remaining: 0 корзина обнуляется и получает notBefore = now + Reset. Без этого своя модель, которая восполняет плавно, выдала бы токен через intervalMs / refillTokens (при 300/мин — через 200 мс), тогда как окно WB сбрасывается целиком: повтор раньше Reset детерминированно ловит 429.

Монотонность с penalize. Наказание после реального 429 длиннее наблюдения сверки не сокращается: 429 — источник авторитетнее.

Сверка синхронна. Между ответом и следующим acquire может не быть ни одного тика (постраничный импорт идёт вплотную), поэтому отложенная запись опоздала бы ровно в том сценарии, ради которого затевалась. Цена — один Redis-вызов, и тот отсекается до обращения к Redis, когда Remaining не ниже ёмкости корзины: сузить там нечего по построению.

Сбой сверки не влияет на запрос. Недоступный Redis не должен превращать успешный ответ WB в ошибку — вызывающий логирует и идёт дальше.

Калибровка остаётся ручной. Расхождение Limit с ёмкостью реестра пишется в лог на уровне verbose — как материал для осознанной правки wb-rate-limits.ts, а не как автоматическое действие.

Последствия​

Хорошее. Выход за лимит предотвращается до отказа, а не компенсируется после. Корзина подтягивается к реальности и для остальных потребителей кабинета (состояние общее, в Redis), а не только для текущего потока. Появился материал для калибровки реестра.

Плохое. Заголовки отдают не все эндпоинты WB — там всё работает как прежде. При нескольких корзинах под одним бюджетом WB сужение каждой из них до Remaining не гарантирует, что их сумма уложится в бюджет: это по-прежнему приближение, и penalize после 429 остаётся нужен. Lua-скрипт RECONCILE_LUA — третье место, зеркалящее арифметику wb-token-bucket.ts; расхождение property-тесты поймают только для TS-ветки.