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

0006. Rate-limit и retry для WB API

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

  • Статус: accepted; алгоритм лимитера заменён в ADR-0041 (token-bucket), политика задержки на 429 — в ADR-0048
  • Дата: 2026-06-01
  • Связанные задачи: OP-34 (F3 · WB Integration Core)

Уточнения по итогам OP-220 (2026-08-20), см. ADR-0048.

  1. Требование 1 ниже («нельзя допустить ситуацию „запрос ушёл в WB и получил 429“») недостижимо и опровергнуто продом: локальная модель бакета систематически оптимистичнее счётчика WB (мы меряем момент отправки, WB — момент приёма; ключ может быть вытеснен из Redis; скью часов; наш ключ строится по нашему uuid кабинета). 429 трактуется как штатная обратная связь, по которой бакет штрафуется (WbRateLimiter.penalize).
  2. Утверждение «на 429 WB присылает Retry-After» (раздел «Retry-policy») официальной спекой не подтверждается: заголовок не описан ни у одного ответа во всех 13 YAML, тело 429 — application/problem+json без поля со временем ожидания. На фидах оцифровки он не приходит.
  3. Вывод «разные политики на 429 vs 5xx усложняют без выигрыша» отменён: политики разделены. На 429 пауза не короче восполнения одного токена бакета; 5xx и сетевые сбои сохраняют прежнюю короткую экспоненту.

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

WB API имеет жёсткие лимиты per-account на каждый домен/endpoint, при превышении токен временно блокируется на стороне WB. Фичевые модули (автобиддер, репрайсер, аналитика РНП) будут опрашивать API ежеминутно или чаще — без централизованного лимитера это неизбежно приведёт к блокировкам кабинетов селлеров и потере доверия к продукту.

Нужно три механизма:

  1. Локальное ограничение частоты перед отправкой запроса — нельзя допустить ситуацию «запрос ушёл в WB и получил 429».
  2. Retry с backoff на 429 / 5xx / сетевые сбои — большинство временных проблем решаются повторной попыткой с задержкой.
  3. Верхний предел ожидания, после которого вызов «сдаётся» — синхронные HTTP-эндпоинты не должны висеть минутами.

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

Алгоритм лимитера​

  1. Fixed-window counter на Redis (INCR + EXPIRE).
    • Очень дёшево (один Lua-скрипт, два O(1) Redis-вызова), просто понять и отладить. − «Дребезг» на границе окна: теоретически в окрестности перехода возможен 2× лимита за короткий промежуток.
  2. Sliding-window log через ZADD/ZREMRANGEBYSCORE sorted-set.
    • Точно: на любом скользящем интервале не больше tokens запросов. − Дороже по памяти и CPU; нужны TTL-метки, чистка хвоста; в худшем случае O(log N) на запрос.
  3. Token-bucket с непрерывным refill (tokens + lastRefillAt).
    • Стандарт для лимитеров API; даёт «всплески» в рамках capacity. − Сложнее реализовать атомарно на Redis; нужен Lua с математикой по времени.

Изоляция между кабинетами​

  • Per-account ключ (wb:rl:{accountId}:{scope}:{path}) — WB лимитирует именно на стороне токена кабинета. Лимит per-account — это правильно семантически.
  • Глобальный ключ на всё приложение — было бы хуже: один селлер с тяжёлым опросом «съел» бы лимит для остальных.

Retry-policy​

  • Один экспоненциальный backoff на все ретраируемые ошибки — просто и предсказуемо.
  • Разные политики на 429 vs 5xx — точнее (на 429 WB присылает Retry-After, на 5xx — нет), но усложняет без выигрыша; на 429 без заголовка достаточно того же backoff.

Решение​

Fixed-window counter + Lua + per-account ключ​

  • Алгоритм — fixed-window counter в Redis с TTL = intervalMs. Lua-скрипт делает атомарно: INCR(key) → если 1, PEXPIRE; если count <= tokens, разрешает; иначе DECR(key) + возвращает оставшийся PTTL. Скрипт находится в wb-rate-limiter.ts: ACQUIRE_LUA.
  • Ключ — wb:rl:{accountId}:{scope}:{path}. accountId опционален; для сервисных вызовов без accountId используется _global.
  • Реестр лимитов — wb-rate-limits.ts: WB_DEFAULT_RATE_LIMITS (scope-level дефолты) + WB_ENDPOINT_RATE_LIMITS (override per endpoint). Резолвер resolveRateLimit(scope, path) — единая точка.
  • Локальное ожидание — acquire() ждёт setTimeout(waitMs) до открытия окна; общий потолок — WB_API_RATE_LIMIT_MAX_WAIT_MS (30s), при превышении бросает WbApiRateLimitedError. Дополнено: с OP-192 конфиг — нижняя граница, эффективный потолок — max(конфиг, окно эндпоинта + 5 c) (иначе 1/min-фиды обрывались на 2-й странице); с OP-209 для лимитов со скоростью 1 запрос/окно (tokens === 1) — два окна (2 × окно + 5 c: проигравший стык при конкуренции двух потребителей должен дожить до следующего стыка), а локальный отказ несёт retryAfterSeconds = остаток окна (клиент получает Retry-After).
  • Дребезг fixed-window — приемлем для текущих WB-лимитов (десятки запросов/мин на кабинет). Если позже фичевый модуль упрётся в дребезг, мигрируем на sliding-log отдельным тикетом — резолвер лимита изолирован от алгоритма, что упростит переход.

Retry с backoff + jitter​

  • maxAttempts = WB_API_RETRY_ATTEMPTS (по умолчанию 3 = 1 основная

    • 2 retry).
  • Ретраим: WbApiRateLimitedError (429), WbApiServerError (5xx), WbApiNetworkError/WbApiError без статуса (таймаут / DNS / TCP).

  • Не ретраим: 401/403/404 — это «логические» ошибки, не починятся через секунду.

  • Задержка (заменено в ADR-0048; исходная редакция OP-34):

    • 429 с Retry-After: N → min(N*1000, WB_API_RATE_LIMIT_MAX_WAIT_MS);
    • иначе backoff baseDelayMs * 2^(attempt-1) + jitter [0..baseDelayMs).

    Актуально: на 429 — min( max(Retry-After, refillOneTokenMs(бакет), backoff), rateLimitWaitCeilingMs(бакет) ); на 5xx/сеть — прежний backoff без пола окна.

  • Per-attempt rate-limit — перед каждой попыткой снова acquire(), чтобы retry не «обходил» локальный лимитер. NB (OP-220): следствие этого решения — короткая пауза ретрая для бакетов ёмкостью 1 целиком поглощается ожиданием acquire. Именно поэтому «просто увеличить задержку» на таких эндпоинтах ничего не меняет, а помогает штраф бакета + лестница на уровне джобы.

Конфигурация через env​

  • WB_API_RETRY_ATTEMPTS=3
  • WB_API_RETRY_BASE_MS=500
  • WB_API_RATE_LIMIT_MAX_WAIT_MS=30000

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

  • Положительные:
    • WB-кабинеты защищены от блокировки даже при ошибках расписания (если кто-то случайно настроит опрос чаще лимита — лимитер «придержит»).
    • 429/5xx переживаются прозрачно для вызывающего кода: либо вернёт результат, либо бросит понятную доменную ошибку.
    • Конфигурация на уровне env — можно тюнить per-окружение (в Coolify разные значения для prod vs stage).
  • Отрицательные / компромиссы:
    • Fixed-window даёт дребезг на стыке окон. Принято: при текущих лимитах ущерб невелик; миграция на sliding-log — отдельный тикет.
    • Retry на 5xx может удвоить/утроить трафик к WB в момент сбоя на их стороне. Принято: maxAttempts=3 ограничивает амплификацию. (OP-220: Retry-After от WB на фидах не приходит — WB его не документирует; ограничителем служит пол по окну бакета, ADR-0048.) Метрики квот для мониторинга — придут в OP-36.
    • Token-bucket с capacity = tokens за intervalMs НЕ моделирует непрерывное пополнение (стандартный token-bucket). Это fixed-window-counter, не «настоящий» bucket. Принято: для WB-лимитов это эквивалентно, описание реестра честно использует термин «окно».
  • Дальше (вне scope OP-34):
    • OP-36 — нормализация ошибок WB и мониторинг квот (метрики использования per-account).
    • Расширение WB_ENDPOINT_RATE_LIMITS точечными лимитами по мере появления фичевых модулей 5.1–5.15.
    • Возможный переход на sliding-window log, если упрёмся в дребезг fixed-window.