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

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 = «окно занято». Две проблемы:

  1. Не использовалась burst-ёмкость WB. Официальные лимиты WB (Swagger-снимок 24.07.2026, на аккаунт продавца) включают колонку «Всплеск»: orders 1/мин всплеск 10, warehouse_remains всплеск 5, воронка v3 всплеск 3, sales всплеск 1. Начальная оцифровка большого кабинета могла бы забрать первые ~10 страниц orders залпом, а не ждать по окну на страницу.
  2. «Дребезг» на стыке окон. Fixed-window допускает до 2× лимита на границе двух окон — риск 429 на эндпоинтах без всплеска (sales).

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

  1. Token-bucket на Redis-хэше {t, ts} (Lua). Корзина ёмкостью burst восполняется со скоростью tokens/intervalMs; атомарный пересчёт+списание в Lua. Плюсы: прямо моделирует «Всплеск» WB; нет дребезга (сглаженная установившаяся скорость); дёшево (один хэш-ключ на бакет). Минусы: состояние — хэш (не счётчик), нужна адаптация quota; арифметика продублирована в Lua и TS (для тестируемости).
  2. 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 там, где всплеск > скорости: orders 10, 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/мин-бакетах.