fix(tradein-browser): in-page fetch retry + configurable settle for /fetch-json (#1917)
First XHR after goto(origin) intermittently hit "NetworkError when attempting to fetch resource" (page net stack/anti-bot not ready), healed only by the outer re-navigation retry (~30-45s/house in the house_imv_backfill, #562). _fetch_json_once now: - settles FETCH_JSON_SETTLE_MS (default 1200, was hardcoded 500) — fewer first-fails; - wraps the in-page fetch() in a JS retry loop (FETCH_JSON_INPAGE_RETRIES default 1, FETCH_JSON_RETRY_DELAY_MS default 800) that retries ONLY on network throw, never on HTTP status (4xx/5xx short-circuit, caller decides). An in-page retry costs ~retryDelayMs vs the ~30-45s outer re-navigation. Last error re-thrown — outer crash-retry contract intact. /fetch (SERP) path untouched. +2 tests (settle ms, retry params). Refs #1917, #562
This commit is contained in:
parent
dfdd978528
commit
2687f75b92
2 changed files with 97 additions and 14 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue