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.
|
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
|
||||||
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
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" — фолбэк для всех прочих хостов (один общий
|
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
|
||||||
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
|
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
|
||||||
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic")
|
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic")
|
||||||
|
|
@ -812,21 +825,44 @@ async def _fetch_json_once(
|
||||||
await _pace_provider(provider)
|
await _pace_provider(provider)
|
||||||
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
|
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
|
||||||
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
|
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
|
||||||
# same-origin scope для fetch), а не отрендеренные listings. Короткой паузы
|
# same-origin scope для fetch), а не отрендеренные listings. Settle-паузы
|
||||||
# хватает, чтобы страница инициализировалась перед in-page fetch'ем.
|
# (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась
|
||||||
await page.wait_for_timeout(500) # type: ignore[attr-defined]
|
# перед 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]
|
result: dict = await page.evaluate( # type: ignore[attr-defined]
|
||||||
"""async ({url, method, headers, body}) => {
|
"""async ({url, method, headers, body, retries, retryDelayMs}) => {
|
||||||
const resp = await fetch(url, {
|
let lastErr;
|
||||||
method: method || 'GET',
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
headers: headers || {},
|
try {
|
||||||
body: (body !== null && body !== undefined) ? body : undefined,
|
const resp = await fetch(url, {
|
||||||
credentials: 'include',
|
method: method || 'GET',
|
||||||
});
|
headers: headers || {},
|
||||||
const text = await resp.text();
|
body: (body !== null && body !== undefined) ? body : undefined,
|
||||||
return { status: resp.status, body: text };
|
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:
|
finally:
|
||||||
await page.close() # type: ignore[attr-defined]
|
await page.close() # type: ignore[attr-defined]
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ class _FakePage:
|
||||||
|
|
||||||
def __init__(self, evaluate_result: dict[str, Any]) -> None:
|
def __init__(self, evaluate_result: dict[str, Any]) -> None:
|
||||||
self.goto_urls: list[str] = []
|
self.goto_urls: list[str] = []
|
||||||
|
self.waits: list[int] = [] # записанные wait_for_timeout(ms) — settle-проверка #1917
|
||||||
self.closed = 0
|
self.closed = 0
|
||||||
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
|
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
|
||||||
self.evaluate = AsyncMock(return_value=evaluate_result)
|
self.evaluate = AsyncMock(return_value=evaluate_result)
|
||||||
|
|
@ -77,7 +78,7 @@ class _FakePage:
|
||||||
self.goto_urls.append(url)
|
self.goto_urls.append(url)
|
||||||
|
|
||||||
async def wait_for_timeout(self, ms: int) -> None:
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
return None
|
self.waits.append(ms)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
self.closed += 1
|
self.closed += 1
|
||||||
|
|
@ -205,6 +206,52 @@ def test_fetch_json_passes_post_method_and_body(monkeypatch: pytest.MonkeyPatch)
|
||||||
assert payload["body"] == '{"a": 1}'
|
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 ────────────────────────────────────────────────────────
|
# ── валидация / resilience ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue