0041. WB rate-limiter: token-bucket со всплеском вместо fixed-window
Копия ADR из репозитория:
backend/docs/adr/0041-wb-ratelimiter-token-bucket.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-07-27
- Связанные задачи: OP-218 (F5); связано: OP-34, OP-192, OP-196, OP-209, ADR-0006
Контекст и проблема
Лимитер WB API (wb-rate-limiter.ts) был fixed-window counter: Lua INCR +
PEXPIRE(intervalMs), счётчик выше tokens = «окно занято». Две проблемы:
- Не использовалась burst-ёмкость WB. Официальные лимиты WB (Swagger-снимок
24.07.2026, на аккаунт продавца) включают колонку «Всплеск»:
orders1/мин всплеск 10,warehouse_remainsвсплеск 5, воронка v3 всплеск 3,salesвсплеск 1. Начальная оцифровка большого кабинета могла бы забрать первые ~10 страниц orders залпом, а не ждать по окну на страницу. - «Дребезг» на стыке окон. Fixed-window допускает до 2× лимита на границе
двух окон — риск 429 на эндпоинтах без всплеска (
sales).
Рассмотренные варианты
- Token-bucket на Redis-хэше
{t, ts}(Lua). Корзина ёмкостьюburstвосполняется со скоростьюtokens/intervalMs; атомарный пересчёт+списание в Lua. Плюсы: прямо моделирует «Всплеск» WB; нет дребезга (сглаженная установившаяся скорость); дёшево (один хэш-ключ на бакет). Минусы: состояние — хэш (не счётчик), нужна адаптация quota; арифметика продублирована в Lua и TS (для тестируемости). - Sliding-log на sorted-set. Хранить таймстемпы запросов в ZSET, чистить окном. Плюсы: точность без дребезга. Минусы: O(log N) память/CPU на запрос, ZREMRANGEBYSCORE на каждый вызов — дороже token-bucket при равном результате для burst-семантики; не даёт «всплеска» естественно.
Решение
Вариант 1 — token-bucket. Реализация:
- Чистая функция
evaluateTokenBucket/peekTokens(wb-token-bucket.ts) — эталон арифметики, покрыт property-тестами на детерминированной временной шкале (burst без ожиданий, установившаяся скорость ≤ лимит, устранение дребезга приburst=1, клампинг скью). Lua-скрипт зеркалит её строка-в- строку — атомарность требует Lua на Redis, тестируемость требует чистого TS; диффы держим идентичными (в TSDoc — явное требование синхронизации). nowпередаётся из приложения (ARGV), неredis.TIME: детерминизм для тестов + безопасность репликации (запись послеTIMEнедетерминирована). Скью часов между воркерами для rate-limit допустим; отрицательныйelapsedклампится в 0.burst— новое опциональное полеWbRateLimitBucket(дефолт =tokens, ёмкость черезbucketCapacity). Заполнено из Swagger там, где всплеск > скорости:orders10,warehouse_remains(создание) 5. Остальные — без всплеска (burst = tokens).- Контракт
acquireсохранён ([allowed, waitMs], локальный 429 сretryAfterSeconds). Потолок ожидания OP-209 (tokens === 1→ 2 окна) считается поtokens(worst-case пауза = восполнение одного токена = окно); приburst = 1поведение прежнее. - quota-сервис читает хэш (
HMGET t, ts) и пересчитывает доступные токены наnowбез списания (peekTokens):used = capacity − доступные,resetsInMs= до полного восполнения. - TTL ключа = полное восполнение пустой корзины + окно запаса
(
tokenBucketTtlMs): пока корзина не полна — состояние живёт; после восполнения в простое ключ истекает и следующий запрос видит полную корзину (эквивалентно хранению). - Версия формата в неймспейсе ключа (
wb:rl:→wb:rl:v2:): значение сменило тип с string-счётчика (INCR) на хэш{t, ts}. По одному и тому же ключуHMGETна оставшемся после деплоя string-ключе упал бы сWRONGTYPE(и вacquire, и в quota-Promise.all). Сегментv2разводит форматы: старыеv1-ключи осиротеют и истекут по своему TTL (≤ одно окно, ≤ 60 c), новыеv2-ключи стартуют с чистой полной корзины. Онлайн-миграция не нужна.
Последствия
- Положительные: burst-серии проходят без искусственных пауз (быстрее
начальная оцифровка); устранён стыковой дребезг (меньше 429 на
salesи др. безвсплесковых); лимиты точнее соответствуют официальной таблице WB. - Отрицательные / компромиссы: арифметика token-bucket продублирована в Lua и TS — риск расхождения при односторонней правке (митигируется явным требованием синхронизации в TSDoc; property-тесты покрывают TS-ветку); состояние бакета — хэш, а не счётчик (quota-снимок усложнился на пересчёт).
- Дальше: при необходимости точной аудитной статистики per-запрос — sliding
log отдельным тикетом; синергия с неблокирующим ожиданием лимитера (reschedule
вместо блокирующего
acquire) — снимет риск, что concurrency-слоты (OP-216) одновременно «уснут» на 1/мин-бакетах.