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 ниже («нельзя допустить ситуацию „запрос ушёл в WB и получил 429“») недостижимо и опровергнуто продом: локальная модель бакета систематически оптимистичнее счётчика WB (мы меряем момент отправки, WB — момент приёма; ключ может быть вытеснен из Redis; скью часов; наш ключ строится по нашему uuid кабинета). 429 трактуется как штатная обратная связь, по которой бакет штрафуется (
WbRateLimiter.penalize).- Утверждение «на 429 WB присылает
Retry-After» (раздел «Retry-policy») официальной спекой не подтверждается: заголовок не описан ни у одного ответа во всех 13 YAML, тело 429 —application/problem+jsonбез поля со временем ожидания. На фидах оцифровки он не приходит.- Вывод «разные политики на 429 vs 5xx усложняют без выигрыша» отменён: политики разделены. На 429 пауза не короче восполнения одного токена бакета; 5xx и сетевые сбои сохраняют прежнюю короткую экспоненту.
Контекст и проблема
WB API имеет жёсткие лимиты per-account на каждый домен/endpoint, при превышении токен временно блокируется на стороне WB. Фичевые модули (автобиддер, репрайсер, аналитика РНП) будут опрашивать API ежеминутно или чаще — без централизованного лимитера это неизбежно приведёт к блокировкам кабинетов селлеров и потере доверия к продукту.
Нужно три механизма:
- Локальное ограничение частоты перед отправкой запроса — нельзя допустить ситуацию «запрос ушёл в WB и получил 429».
- Retry с backoff на 429 / 5xx / сетевые сбои — большинство временных проблем решаются повторной попыткой с задержкой.
- Верхний предел ожидания, после которого вызов «сдаётся» — синхронные HTTP-эндпоинты не должны висеть минутами.
Рассмотренные варианты
Алгоритм лимитера
- Fixed-window counter на Redis (
INCR+EXPIRE).- Очень дёшево (один Lua-скрипт, два O(1) Redis-вызова), просто понять и отладить. − «Дребезг» на границе окна: теоретически в окрестности перехода возможен 2× лимита за короткий промежуток.
- Sliding-window log через
ZADD/ZREMRANGEBYSCOREsorted-set.- Точно: на любом скользящем интервале не больше
tokensзапросов. − Дороже по памяти и CPU; нужны TTL-метки, чистка хвоста; в худшем случае O(log N) на запрос.
- Точно: на любом скользящем интервале не больше
- 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 без пола окна. - 429 с
-
Per-attempt rate-limit — перед каждой попыткой снова
acquire(), чтобы retry не «обходил» локальный лимитер. NB (OP-220): следствие этого решения — короткая пауза ретрая для бакетов ёмкостью 1 целиком поглощается ожиданиемacquire. Именно поэтому «просто увеличить задержку» на таких эндпоинтах ничего не меняет, а помогает штраф бакета + лестница на уровне джобы.
Конфигурация через env
WB_API_RETRY_ATTEMPTS=3WB_API_RETRY_BASE_MS=500WB_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.