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