fix(tradein-browser): in-page fetch retry + settle tuning for /fetch-json (#1917) #1938

Merged
bot-backend merged 1 commit from fix/1917-fetch-json-network-retry into main 2026-06-27 00:31:20 +00:00
2 changed files with 97 additions and 14 deletions

View file

@ -98,6 +98,19 @@ BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "6000
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
# Поднимаем дефолт до 1200мс (меньше first-fail) + добавляем дешёвый in-page retry
# самого fetch() (см. FETCH_JSON_INPAGE_RETRIES) — повтор внутри страницы стоит ~доли
# секунды против полной ре-навигации. НЕ трогаем /fetch (SERP): там свой BROWSER_WAIT_MS.
FETCH_JSON_SETTLE_MS: int = int(os.environ.get("FETCH_JSON_SETTLE_MS", "1200"))
# Сколько раз повторить in-page fetch() при сетевом throw (TypeError/NetworkError),
# НЕ при HTTP-статусе (4xx/5xx возвращаются как есть — их решает caller). 0 = выкл.
FETCH_JSON_INPAGE_RETRIES: int = int(os.environ.get("FETCH_JSON_INPAGE_RETRIES", "1"))
# Пауза между in-page попытками fetch(), мс.
FETCH_JSON_RETRY_DELAY_MS: int = int(os.environ.get("FETCH_JSON_RETRY_DELAY_MS", "800"))
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic")
@ -812,21 +825,44 @@ async def _fetch_json_once(
await _pace_provider(provider)
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
# same-origin scope для fetch), а не отрендеренные listings. Короткой паузы
# хватает, чтобы страница инициализировалась перед in-page fetch'ем.
await page.wait_for_timeout(500) # type: ignore[attr-defined]
# same-origin scope для fetch), а не отрендеренные listings. Settle-паузы
# (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась
# перед in-page fetch'ем.
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
# In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"),
# #1917: первый XHR после навигации иногда падает до готовности стека. Повтор
# внутри страницы (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом).
# HTTP-статусы (4xx/5xx) — это успешный resp, НЕ ретраим (решает caller).
result: dict = await page.evaluate( # type: ignore[attr-defined]
"""async ({url, method, headers, body}) => {
const resp = await fetch(url, {
method: method || 'GET',
headers: headers || {},
body: (body !== null && body !== undefined) ? body : undefined,
credentials: 'include',
});
const text = await resp.text();
return { status: resp.status, body: text };
"""async ({url, method, headers, body, retries, retryDelayMs}) => {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const resp = await fetch(url, {
method: method || 'GET',
headers: headers || {},
body: (body !== null && body !== undefined) ? body : undefined,
credentials: 'include',
});
const text = await resp.text();
return { status: resp.status, body: text, attempts: attempt + 1 };
} catch (e) {
lastErr = e;
if (attempt < retries) {
await new Promise(r => setTimeout(r, retryDelayMs));
}
}
}
throw lastErr;
}""",
{"url": url, "method": method, "headers": headers or {}, "body": body},
{
"url": url,
"method": method,
"headers": headers or {},
"body": body,
"retries": FETCH_JSON_INPAGE_RETRIES,
"retryDelayMs": FETCH_JSON_RETRY_DELAY_MS,
},
)
finally:
await page.close() # type: ignore[attr-defined]

View file

@ -66,6 +66,7 @@ class _FakePage:
def __init__(self, evaluate_result: dict[str, Any]) -> None:
self.goto_urls: list[str] = []
self.waits: list[int] = [] # записанные wait_for_timeout(ms) — settle-проверка #1917
self.closed = 0
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
self.evaluate = AsyncMock(return_value=evaluate_result)
@ -77,7 +78,7 @@ class _FakePage:
self.goto_urls.append(url)
async def wait_for_timeout(self, ms: int) -> None:
return None
self.waits.append(ms)
async def close(self) -> None:
self.closed += 1
@ -205,6 +206,52 @@ def test_fetch_json_passes_post_method_and_body(monkeypatch: pytest.MonkeyPatch)
assert payload["body"] == '{"a": 1}'
# ── settle + in-page retry (#1917) ─────────────────────────────────────────────────
def test_fetch_json_settle_uses_configured_ms(monkeypatch: pytest.MonkeyPatch) -> None:
"""Settle перед in-page fetch = FETCH_JSON_SETTLE_MS (а не хардкод 500)."""
page = _FakePage({"status": 200, "body": "ok"})
server._browsers["avito"] = _FakeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
monkeypatch.setattr(server, "FETCH_JSON_SETTLE_MS", 1777)
async def _ensure(provider: str) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
asyncio.run(
server.fetch_json_handler(
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
)
)
assert page.waits == [1777]
def test_fetch_json_passes_inpage_retry_params(monkeypatch: pytest.MonkeyPatch) -> None:
"""В page.evaluate прокидываются retries/retryDelayMs из конфигурации (#1917)."""
page = _FakePage({"status": 200, "body": "ok", "attempts": 1})
server._browsers["avito"] = _FakeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
monkeypatch.setattr(server, "FETCH_JSON_INPAGE_RETRIES", 2)
monkeypatch.setattr(server, "FETCH_JSON_RETRY_DELAY_MS", 654)
async def _ensure(provider: str) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
asyncio.run(
server.fetch_json_handler(
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
)
)
payload = page.evaluate.await_args.args[1]
assert payload["retries"] == 2
assert payload["retryDelayMs"] == 654
# ── валидация / resilience ────────────────────────────────────────────────────────