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

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. Исходная гипотеза «клиент ждёт ~1 с вместо окна 60 с» неверна. rateLimiter.acquire() вызывается внутри retry-цикла (это решение ADR-0006: «перед каждой попыткой снова acquire()»), поэтому короткий сон целиком поглощается последующим ожиданием бакета. Для sales (ёмкость 1) HTTP-запросы уходили в +60 / +120 / +180 c независимо от baseDelayMs; подъём задержки до окна дал бы побитово тот же график.

  2. Настоящая связка причин — календарная, а не «одна строка»:

    • модель бакета систематически оптимистичнее счётчика 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 минут — это длительность терминальной фазы, а не всего прогона; таблица ниже сравнивает именно её.

  3. Для бакетов со всплеском короткая пауза — самостоятельный баг. У 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 ч. Кодовая база типы токенов не моделирует (см. «Что дальше»).

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

  1. Поднять WB_API_RETRY_BASE_MS до окна (как предлагалось в тикете). Отвергнуто: доказанный no-op для sales — пауза поглощается acquire(); при этом на быстрых эндпоинтах она вредна (наказание минутой за 429 на prices, где окно 600 мс).

  2. Просто увеличить число попыток. Отвергнуто как самостоятельное решение: больше запросов к уже отказывающему WB — это амплификация, а не лечение; ADR-0006 ограничил maxAttempts ровно из этих соображений.

  3. Ждать в ретрае + штрафовать бакет + растянуть лестницу джобы. Выбрано. Сон защищает текущий поток, штраф — всех остальных потребителей бакета кабинета, экспонента на уровне джобы даёт охват, которого не даёт ни один из первых двух вариантов.

Решение​

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-зеркало», требующую ручной синхронизации.
  • Отношение к прежним решениям:

    • ADR-0006 — посылка «нельзя допустить ситуацию „запрос ушёл в WB и получил 429“» опровергнута продом: 429 неизбежен и теперь трактуется как штатный сигнал. Утверждение «на 429 WB присылает Retry-After» спекой не подтверждается. Раздел про единую политику задержки для 429 и 5xx заменён раздельными ветками;
    • ADR-0042 — решение «не менять семантику user-джоб той же очереди» отменяется в части ретраев: у user-джоб теперь явная per-job политика. Плановые sync-account свою политику сохраняют;
    • ADR-0041 — контракт «Lua зеркалит чистую функцию» распространён на второй скрипт.
  • Что осталось невыполненным из критериев приёмки тикета:

    • AC «отчёт с выручкой доезжает без открытой вкладки» выполнен частично. Импорт фидов теперь переживает окна отказа WB и доходит до completed без участия браузера, но саму джобу отчёта (import-user-report) по-прежнему ставит фронт отдельным вызовом после успеха фидов (frontend/.../queries.ts); серверной цепочки feeds → report в бэкенде нет и никогда не было. Закрыть это — значит принять продуктовое решение (всегда ли импорт фидов должен тянуть за собой импорт отчёта и за какой dateTo), поэтому оно вынесено за рамки багфикса и требует отдельного тикета.
  • Что дальше (отдельными тикетами):

    • Тип токена 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 даёт ему основу: переносить джобу надо ровно на момент открытия бакета.