diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 4540334c..376b3c7c 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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] diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py index d259c05e..ab74fe4a 100644 --- a/tradein-mvp/browser/test_server_fetch_json.py +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -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 ────────────────────────────────────────────────────────