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

0049. Таймаут чтения отчёта реализации и проекция полей WB

Копия ADR из репозитория: backend/docs/adr/0049-finance-report-read-timeout-and-field-projection.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-08-21
  • Связанные задачи: OP-221 (следствие OP-210, соседствует с OP-220)

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

Импорт отчёта о реализации (POST /api/finance/v1/sales-reports/detailed, finance-api) не доезжал до БД: в логах прода запрос помечался как status: 200 + code: WB_NETWORK_ERROR + durationMs: 10002. Таблица wb_report_lines оставалась пустой, а GET /digitization/profit и /financial-report/summary отдавали пустые выборки — в UI комиссия, прибыль, маржа и P&L показывались как «—» при живых фидах продаж.

Причина — один AbortSignal.timeout в WbApiClient.executeOnce накрывает и установку соединения, и заголовки, и response.json(). Страница отчёта — до 100 000 строк по 90 полей, и общего WB_API_TIMEOUT_MS (10 с, рассчитан на короткие ответы фидов) хватает на заголовки, но не на тело. status присваивается до разбора тела, поэтому обрыв чтения выглядел в логах как сетевой сбой, хотя WB запрос принял и ответил.

Ограничения, которые сузили пространство решений:

  • бакет эндпоинта — 1 запрос/мин без всплеска (WB Swagger 13-finances.yaml, wb-rate-limits.ts), поэтому цена лишней страницы — +60 с ожидания;
  • плановый прогон РНП (rnp-finance-sync, cron 05:00 MSK) тянет 14 дней за раз — вдвое больше типичного пользовательского периода;
  • пользовательская джоба import-user-report не имеет resume-курсора (в отличие от import-user-feeds), поэтому каждая повторная попытка качает период с rrdId = 0.

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

  1. Уменьшить limit со 100 000 до 10–20 тыс. и пагинировать по rrdId. Плюс: каждая страница гарантированно укладывается в текущий таймаут. Минус: упирается в лимит 1 запрос/мин — 14-дневный прогон РНП растягивается на десятки минут с занятым бакетом, блокируя пользовательский импорт. Без resume-курсора в джобе любой сбой перекачивает всё заново.

  2. Отдельный длинный таймаут на этот эндпоинт. Плюс: не расходует лимит запросов вообще; механизм per-request timeoutMs уже был в WbApiRequest и не использовался. Минус: не уменьшает объём тела, heap и трафик — лечит симптом, оставляя запас прочности небольшим.

  3. Проекция полей через параметр fields. Плюс: WB отдаёт только запрошенные поля — 27 вместо 90, тело меньше примерно втрое; цена — ноль запросов и ноль ожидания. Минус: колонка raw в wb_report_lines перестаёт быть полной копией строки WB.

  4. Оставить ретрай сетевого таймаута как есть. Минус: каждая повторная попытка снова берёт токен бакета (+60 с), а сверху лестница джобы (5 попыток, 60→120→240→480 с) держит импорт в pending около получаса — и всё это время бакет занят.

  5. Запретить повтор любого таймаута чтения тела, глобально. Минус (найдено на self-review): правило «повтор бессмыслен» верно только там, где таймаут назначен под конкретный ответ. Фиды orders/sales и advert fullstats идут на общем 10-секундном дефолте с телами на десятки мегабайт; глобальный запрет хоронил бы пользовательский импорт на первой же медленной странице — без единого повтора, за 10 секунд.

Решение​

Приняты варианты 2 + 3 одновременно, вариант 1 отклонён, ретрай по варианту 4 убран:

  • WB_REPORT_REQUEST_TIMEOUT_MS = 120_000 передаётся per-request; общий WB_API_TIMEOUT_MS для фидов не меняется;

  • запрос идёт со списком WB_REPORT_FIELDS — ровно те 27 полей, которые читает mapSalesReportDetailedRow; limit остаётся максимальным (100 000);

  • обрыв чтения тела при уже известном статусе выделен в WbApiResponseTimeoutError (WB_RESPONSE_TIMEOUT), несущий фактический HTTP-статус. Повтор зависит от того, кто назначил бюджет времени: запрос с явным timeoutMs не ретраится (вызывающий уже сказал, сколько ответу нужно — повтор упрётся в тот же объём тела и сожжёт токен бакета), а запрос на общем дефолте ретраится как раньше. Второе важно вне отчёта: фиды orders/sales идут на 10-секундном дефолте, и глобальный запрет повтора убивал бы их импорт с первой же медленной страницы. Для отчёта failFastOnFatal заворачивает ошибку в UnrecoverableError — джоба падает за секунды с доменной причиной вместо получаса pending. Хелпер вынесен в digitization/wb-fatal-error.util.ts и применён на обоих путях импорта отчёта: пользовательском (DigitizationSyncProcessor) и плановом (RnpFinanceSyncProcessor, 3 попытки с exponential backoff). Иначе ночной 14-дневный прогон РНП повторял бы неретраибельный таймаут дважды, каждый раз с rrdId = 0 и с новым токеном бакета — ровно то, что запрет повтора и предотвращает;

  • проекция деградирует на первой странице прогона по двум признакам: явный отказ (400 в ответ на запрос с fields) и молчаливый — ответ пришёл, но ни в одной строке нет ни одного денежного поля (WB_REPORT_PROJECTION_PROBE_FIELDS). Второй признак важнее первого: маппер требует только rrdId/nmId, остальное уходит в null, поэтому неверно понятый fields дал бы «успешный» импорт с нулевыми комиссиями и заниженным P&L — хуже пустой таблицы, ради которой заводился тикет. В обоих случаях та же страница перезапрашивается без fields, и прогон продолжается без проекции. 400 на следующих страницах пробрасывается как есть: проекция во всех запросах одна и та же, принятая на первой странице отказать дальше не может, а лишний повтор сжёг бы второй токен бакета и увёл бы диагностику не туда;

  • событие wb-api.request получило поле headersMs (TTFB) рядом с durationMs: разница между ними — время чтения тела, по которому «WB медленно отвечает» отличается от «тело не докачалось».

Полноту WB_REPORT_FIELDS держит тест, а не тип: у SalesReportDetailedRow есть индексная сигнатура [extra: string]: unknown, из-за которой keyof принимает любую строку. Тест в wb-financial-report.mapper.spec.ts маппит строку, урезанную до запрошенного набора, и требует, чтобы ни одна колонка не осталась пустой.

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

  • Положительные: импорт отчёта перестаёт обрываться на 10 с; исправление лежит в самом сервисе, поэтому покрывает оба вызова — пользовательский импорт (OP-193) и плановый rnp-finance-sync (OP-81). Тело, heap и трафик сокращаются примерно втрое. Ошибка таймаута доходит до селлера как «WB API: ответ получен …, но тело не докачалось», а не как «сетевая ошибка». Лимит запросов не расходуется дополнительно.

  • Отрицательные / компромиссы: колонка raw хранит теперь запрошенное подмножество, а не полную строку WB — если для расширения P&L понадобится поле, которого нет в проекции, историю придётся переимпортировать. Размен осознанный: raw в коде только пишется и нигде не читается. Расширение набора полей требует правки WB_REPORT_FIELDS — тест ловит забытое поле, но только для тех, что уже читает маппер.

  • Дальше: у import-user-report по-прежнему нет resume-курсора — при переходе на дробные страницы (если WB ужесточит объём ответа) он станет обязательным. Массовые 429 на фидах (statistics) — отдельная линия OP-220: пустой P&L может держаться и после этого исправления, если фиды не проходят.