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.
Рассмотренные варианты
-
Уменьшить
limitсо 100 000 до 10–20 тыс. и пагинировать поrrdId. Плюс: каждая страница гарантированно укладывается в текущий таймаут. Минус: упирается в лимит 1 запрос/мин — 14-дневный прогон РНП растягивается на десятки минут с занятым бакетом, блокируя пользовательский импорт. Без resume-курсора в джобе любой сбой перекачивает всё заново. -
Отдельный длинный таймаут на этот эндпоинт. Плюс: не расходует лимит запросов вообще; механизм per-request
timeoutMsуже был вWbApiRequestи не использовался. Минус: не уменьшает объём тела, heap и трафик — лечит симптом, оставляя запас прочности небольшим. -
Проекция полей через параметр
fields. Плюс: WB отдаёт только запрошенные поля — 27 вместо 90, тело меньше примерно втрое; цена — ноль запросов и ноль ожидания. Минус: колонкаrawвwb_report_linesперестаёт быть полной копией строки WB. -
Оставить ретрай сетевого таймаута как есть. Минус: каждая повторная попытка снова берёт токен бакета (+60 с), а сверху лестница джобы (5 попыток, 60→120→240→480 с) держит импорт в
pendingоколо получаса — и всё это время бакет занят. -
Запретить повтор любого таймаута чтения тела, глобально. Минус (найдено на self-review): правило «повтор бессмыслен» верно только там, где таймаут назначен под конкретный ответ. Фиды
orders/salesиadvertfullstats идут на общем 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 может держаться и после этого исправления, если фиды не проходят.