0048. 429 от WB как обратная связь: штраф бакета и минутная лестница ретраев
Копия ADR из репозитория:
backend/docs/adr/0048-wb-429-feedback-and-retry-ladder.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-08-20
- Связанные задачи: OP-220 (bug, 5.1 · Оцифровка); уточняет ADR-0006, ADR-0041, ADR-0042
Контекст и проблема
Пользовательский импорт оцифровки (POST /digitization/import → джоба
import-user-feeds) через 20–40 минут уходил в failed с WB_RATE_LIMITED
на GET /api/v1/supplier/sales. Отчёт о реализации после этого не запускался
(его ставит фронт отдельным вызовом), и в UI не было выручки.
Что показало расследование
Симуляция на боевых функциях (evaluateTokenBucket + computeRetryDelay +
resolveRateLimit) в цикле, повторяющем WbApiClient.request:
-
Исходная гипотеза «клиент ждёт ~1 с вместо окна 60 с» неверна.
rateLimiter.acquire()вызывается внутри retry-цикла (это решение ADR-0006: «перед каждой попыткой сноваacquire()»), поэтому короткий сон целиком поглощается последующим ожиданием бакета. Дляsales(ёмкость 1) HTTP-запросы уходили в +60 / +120 / +180 c независимо отbaseDelayMs; подъём задержки до окна дал бы побитово тот же график. -
Настоящая связка причин — календарная, а не «одна строка»:
- модель бакета систематически оптимистичнее счётчика WB: мы отсчитываем
окно от момента отправки, WB — от момента приёма; ключ бакета живёт в
общем Redis и может быть вытеснен;
nowберётся из процесса (положительный скью часов не клампится); ключ строится по нашему uuid кабинета, а WB считает «на аккаунт продавца». Отсюда реальные 429 при формально соблюдённом лимите; - лестница ретраев была откалибрована в секундах, а окна WB — в минутах:
3 HTTP-попытки × 60 c = 3 минуты, дальше BullMQ повторял джобу через
1 / 2 / 4 / 8 c (унаследованные
DEFAULT_JOB_OPTIONS), то есть все 5 попыток джобы сгорали внутри ~15 минут; - перезапуск из UI был холостым:
existing.retry()безresetAttemptsMadeоставлялattemptsMade === attempts, и BullMQ (moveToFinished:HINCRBY atm 1→atm >= attempts) отдавал ровно один прогон с мгновенным падением.
Сверка с наблюдением: модель даёт ~15 минут «чистого» упирания в закрытое окно
sales, тогда как в тикете зафиксированы 20–40 минут доfailed. Разница — работа, которую прогон делает до того, как упрётся: страницыorders(всплеск 10, затем 1/мин) и успешные страницыsalesидут по одной в минуту и сами по себе занимают минуты. То есть 15 минут — это длительность терминальной фазы, а не всего прогона; таблица ниже сравнивает именно её. - модель бакета систематически оптимистичнее счётчика WB: мы отсчитываем
окно от момента отправки, WB — от момента приёма; ключ бакета живёт в
общем Redis и может быть вытеснен;
-
Для бакетов со всплеском короткая пауза — самостоятельный баг. У
orders(burst: 10) после 429 в корзине остаются токены:acquire()пропускает повтор мгновенно, и прежний ~1-секундный backoff давал детерминированный второй 429 за пару секунд.
Что говорит официальная документация WB
По swagger/12-reports.yaml: sales — 1 запрос/мин, всплеск 1 (:282);
orders — 1 запрос/мин, всплеск 10 (:167). Реестр wb-rate-limits.ts
совпадает со спекой дословно — ошибки конфигурации нет.
Существенно: Retry-After в спеке WB не описан нигде (0 совпадений во всех
13 YAML), а тело 429 — application/problem+json без поля со временем
ожидания. Оговорка: спека WB не описывает ни одного response-заголовка,
поэтому строго из неё следует лишь то, что на заголовок нельзя
рассчитывать — не то, что он никогда не приходит. Прод-лог OP-220 это
подтверждает косвенно: waitMs в нём взят из экспоненциальной ветки, то есть
retryAfterSeconds был пуст. Утверждение ADR-0006 «на 429 WB присылает
Retry-After» как гарантия — неверно; политика теперь не зависит от него.
Также спека объявляет третий тип токена — «Базовый», для которого sales
ограничен 1 запросом / 2 ч, а orders — 1 / 3 ч. Кодовая база типы токенов не
моделирует (см. «Что дальше»).
Рассмотренные варианты
-
Поднять
WB_API_RETRY_BASE_MSдо окна (как предлагалось в тикете). Отвергнуто: доказанный no-op дляsales— пауза поглощаетсяacquire(); при этом на быстрых эндпоинтах она вредна (наказание минутой за 429 наprices, где окно 600 мс). -
Просто увеличить число попыток. Отвергнуто как самостоятельное решение: больше запросов к уже отказывающему WB — это амплификация, а не лечение; ADR-0006 ограничил
maxAttemptsровно из этих соображений. -
Ждать в ретрае + штрафовать бакет + растянуть лестницу джобы. Выбрано. Сон защищает текущий поток, штраф — всех остальных потребителей бакета кабинета, экспонента на уровне джобы даёт охват, которого не даёт ни один из первых двух вариантов.
Решение
1. Пол паузы на 429 — восполнение одного токена
computeRetryDelay получает контекст запроса (scope + path) и резолвит
бакет эндпоинта. На 429 берётся
min( max(Retry-After, refillOneTokenMs, exponential), rateLimitWaitCeilingMs ).
refillOneTokenMs = ceil(intervalMs / tokens)— честное время восполнения, поэтому пол масштабируется сам:sales→ 60 000 мс,prices→ 600 мс;- короткий
Retry-Afterподнимается до окна, а не режется конфигом 30 c; - 5xx и сетевые сбои идут прежней короткой экспонентой — чужой сбой не означает нашего перерасхода лимита.
2. Штраф бакета после реального 429 (WbRateLimiter.penalize)
Локальный лимитер бросает свой 429 до try, поэтому в catch клиента
попадает только настоящий отказ WB. Он трактуется как авторитетная обратная
связь: корзина осушается, и в состояние пишется отдельное поле nb
(notBefore) — момент, раньше которого токен не выдаётся, даже если он уже
восполнился. Операция монотонна (не ослабляет более длинное наказание) и
выполняется в том числе на последней попытке — иначе следующий прогон
джобы стартовал бы в то же закрытое окно.
Почему отдельное поле, а не сдвиг ts в будущее. Такой вариант был
реализован первым и отвергнут по результатам ревью: acquire безусловно
пишет ts = now в обеих ветках, поэтому будущая метка стиралась первым же
обращением любого потребителя, и наказание схлопывалось до одного окна — ровно
там, где оно и должно было работать (Retry-After длиннее окна). Кроме того,
«ts в будущем» уже занято семантикой ретроградного скью часов, который
peekTokens обязан клампить, — то есть одно поле пришлось бы читать двумя
взаимоисключающими способами. Регрессия закрыта тестом
«штраф длиннее окна переживает acquire других потребителей».
Старые ключи без nb читаются как «наказания нет», поэтому смена неймспейса
ключа (как в OP-218) не потребовалась. TTL при наказании считается от
итогового nb: истеки ключ раньше — исчезнувший ключ читался бы как
свежая полная корзина и «простил» бы штраф.
Lua-скрипты ACQUIRE_LUA и PENALIZE_LUA зеркалят чистые функции
evaluateTokenBucket / penalizeTokenBucket — тот же контракт «TS-эталон +
Lua-зеркало», что и в ADR-0041. Сбой Redis при штрафе логируется, но не
подменяет доменный 429: сон остаётся защитой текущего потока.
2а. Фатальные ошибки не идут по минутной лестнице
Лестница из п. 4 рассчитана на 429. Для ошибок, которые повтором не лечатся
(401 — токен отозван, 403, 404), она вредна: импорт по мёртвому кабинету висел
бы в pending ~15 минут вместо секунд. Процессор заворачивает их в
UnrecoverableError, используя тот же критерий isRetryable, что и
HTTP-клиент, — одна точка правды на оба уровня повторов.
3. Общий потолок ожидания
Логика max(конфиг, N × окно + 5 c) вынесена из WbRateLimiter в
rateLimitWaitCeilingMs (wb-rate-limits.ts) и используется обеими сторонами.
Инвариант: потолок паузы ретрая тождественно равен потолку ожидания
acquire — иначе штраф мог бы превысить потолок и вызвать самопричинённый
429 ещё до похода в WB.
4. Минутная лестница у пользовательских джоб
import-user-feeds и import-user-report ставятся с явными
attempts: 5 и backoff: exponential, delay: 60 000 (60 → 120 → 240 → 480 c)
вместо унаследованных из DEFAULT_JOB_OPTIONS 1 / 2 / 4 / 8 c.
Почему именно экспонента, а не фиксированная минута: пауза, не превышающая окно, целиком поглощается ожиданием бакета внутри следующего прогона, поэтому охват остаётся ~15 минут (5 попыток × 3 HTTP по одной в минуту). Экспонента добавляет чистое ожидание и растягивает устойчивость до ~26 минут — измерено симуляцией на боевом коде:
| Окно отказа WB | Было | Стало |
|---|---|---|
| 15 мин | доехал за 15 мин | доехал за 15 мин |
| 20 мин | failed на 15 мин | доехал за 24 мин |
| 30 мин | failed на 15 мин | failed на 26 мин |
Паузы проходят в состоянии delayed — слот воркера свободен.
5. Перезапуск из UI действительно перезапускает
existing.retry('failed', { resetAttemptsMade: true }). Как следствие,
признаком повторного прогона перестал быть attemptsMade, поэтому
importUserFeeds резюмится по наличию job.data.cursor. Первый прогон
по-прежнему уважает запрошенный dateFrom: свежая джоба ставится без курсора,
а завершённая удаляется и добавляется заново.
Последствия
-
Положительные:
- импорт переживает окна отказа WB примерно вдвое дольше; отчёт с выручкой доезжает без открытой вкладки;
- 429 на бакетах со всплеском больше не выжигает попытки за секунды;
- штраф защищает все потоки кабинета (плановый
sync-account, вторая user-джоба, другой под), а не только тот, что поймал 429; - повторный импорт из UI после падения снова получает полноценные 5 попыток;
- потолок ожидания перестал быть продублированной константой.
-
Отрицательные / компромиссы:
- 429 на синхронных путях (например
common:/pingвWbAccountsService.check, окно 10 c на токен) теперь спит 10 c вместо 500 мс. Профиль риска не новый (acquireтам и раньше мог блокировать до 35 c), но стал заметнее; - терминальное падение наступает позже (~26 мин против ~15) — пользователь
дольше видит
pendingбез прогресса. Прогресс-индикация импорта — вне scope этого решения; - user-джоба теперь проводит в состоянии
delayedдо ~13 минут (паузы 60+120+240+480 c), аUserImportActivityServiceсчитаетdelayedживым импортом — фан-аут яруса «инкремент» будет пропускать этот кабинет несколько тиков подряд (раньше паузы складывались в 15 c). Свежесть восстанавливается следующим тиком после терминального исхода; данные не теряются (бэкфилл идемпотентен), но лаг растёт; - штраф бакета, наложенный user-импортом, отодвигает и плановый синк того же кабинета. Это семантически верно (счётчик WB общий на кабинет) и ограничено тем, что фан-аут пропускает кабинет с активным user-импортом;
penalizeдобавляет один Redis-вызов на каждый реальный 429 и вторую пару «TS-эталон + Lua-зеркало», требующую ручной синхронизации.
- 429 на синхронных путях (например
-
Отношение к прежним решениям:
- ADR-0006 — посылка «нельзя допустить ситуацию „запрос ушёл в WB и
получил 429“» опровергнута продом: 429 неизбежен и теперь трактуется как
штатный сигнал. Утверждение «на 429 WB присылает
Retry-After» спекой не подтверждается. Раздел про единую политику задержки для 429 и 5xx заменён раздельными ветками; - ADR-0042 — решение «не менять семантику user-джоб той же очереди»
отменяется в части ретраев: у user-джоб теперь явная per-job политика.
Плановые
sync-accountсвою политику сохраняют; - ADR-0041 — контракт «Lua зеркалит чистую функцию» распространён на второй скрипт.
- ADR-0006 — посылка «нельзя допустить ситуацию „запрос ушёл в WB и
получил 429“» опровергнута продом: 429 неизбежен и теперь трактуется как
штатный сигнал. Утверждение «на 429 WB присылает
-
Что осталось невыполненным из критериев приёмки тикета:
- AC «отчёт с выручкой доезжает без открытой вкладки» выполнен частично.
Импорт фидов теперь переживает окна отказа WB и доходит до
completedбез участия браузера, но саму джобу отчёта (import-user-report) по-прежнему ставит фронт отдельным вызовом после успеха фидов (frontend/.../queries.ts); серверной цепочки feeds → report в бэкенде нет и никогда не было. Закрыть это — значит принять продуктовое решение (всегда ли импорт фидов должен тянуть за собой импорт отчёта и за какойdateTo), поэтому оно вынесено за рамки багфикса и требует отдельного тикета.
- AC «отчёт с выручкой доезжает без открытой вкладки» выполнен частично.
Импорт фидов теперь переживает окна отказа WB и доходит до
-
Что дальше (отдельными тикетами):
- Тип токена WB. Для «Базового» токена
salesограничен 1 запросом / 2 ч — это в 120 раз жёстче нашей модели, и никакая лестница ретраев такой кабинет не спасёт. Дискриминатор по прод-логам: есть ли в прогоне хоть одинwb-api.requestсоstatus: 200наpath: "/api/v1/supplier/sales"после первого. Если нет — нужен отдельный режим лимитов; - Запас в пейсинге. Бакет отсчитывает ровно окно WB, без запаса на разницу «отправка vs приём». Явный небольшой margin снизил бы саму частоту 429 (сейчас лечится реактивно, штрафом);
maxmemory-policyпрод-Redis. Приallkeys-*ключ бакета может быть вытеснен посреди окна → мгновенная выдача токена → 429;- Уникальность кабинета. Дубль записи кабинета с тем же токеном WB даёт два бакета на один счётчик WB;
- Курсор отчёта о реализации. У
import-user-reportрезюма нет:WbFinancialReportServiceкаждую попытку начинает сrrdId = 0при лимите 1 запрос/мин. Новая лестница даёт отчёту 5 попыток, но каждая переигрывает период с начала — корректность не страдает (upsert идемпотентен), страдает wall-clock; - OP-219 (
moveToDelayedвместо сна в воркере) —penalizeдаёт ему основу: переносить джобу надо ровно на момент открытия бакета.
- Тип токена WB. Для «Базового» токена