fix(tradein/browser): гонка «execution context destroyed» — восстановимая, ретрай без relaunch (#2676)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
`_fetch_json_once` делает goto(origin, domcontentloaded) → фиксированный settle → page.evaluate. Если страница за это время уходит в клиентскую навигацию, playwright рвёт JS-контекст и бросает «Execution context was destroyed». Такая ошибка не попадала ни в одну ветку восстановления: `_is_browser_crash` матчит только закрытие цели/браузера/соединения — значит для сайдкара это окончательный отказ, 500 наверх. Правка: 1. `_is_page_context_lost` — отдельный признак, не смешанный с крашем браузера. Маркер взят из САМОГО playwright (driver 1.60.0, coreBundle.js в образе сайдкара), а не из строки в нашем логе: обе формы сообщения — короткая и «..., most likely because of a navigation.» — имеют общий префикс. 2. Повтор на СВЕЖЕЙ странице без `_relaunch_browser`: браузер жив, умерла одна страница. Relaunch стоил бы ~10-20с и тёплый профиль инстанса (cookies, фингерпринт) ради здорового браузера. 3. Повтор ждёт `load` (FETCH_JSON_LOAD_WAIT_MS, best-effort). Без этого повтор попадает ровно в то же окно навигации: в логе один и тот же адрес падал дважды подряд, то есть гонка воспроизводится, а не «рассасывается». Happy-path не удлиняется — ожидание только на ретрае. Ретрай строго один и только по этому признаку: чужая ошибка (напр. ERR_PROXY_CONNECTION_FAILED) уезжает наверх сразу — повтор невосстановимого стоит полной ре-навигации на каждый дом. Refs #2676
This commit is contained in:
parent
9d8114158b
commit
f218f85585
2 changed files with 236 additions and 0 deletions
|
|
@ -125,6 +125,11 @@ FETCH_JSON_SETTLE_MS: int = int(os.environ.get("FETCH_JSON_SETTLE_MS", "1200"))
|
|||
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"))
|
||||
# Сколько ждать события `load` на ПОВТОРЕ после гонки «execution context destroyed»
|
||||
# (#2676). Только на повторе: happy-path остаётся на дешёвом FETCH_JSON_SETTLE_MS,
|
||||
# иначе бесконечно дозагружающаяся страница удлиняла бы КАЖДЫЙ запрос. Ожидание
|
||||
# best-effort — по таймауту всё равно пробуем evaluate.
|
||||
FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15000"))
|
||||
|
||||
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
|
||||
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
|
||||
|
|
@ -1000,6 +1005,27 @@ async def _do_fetch_json(
|
|||
return await _fetch_json_once(
|
||||
provider, url, method=method, headers=headers, body=body, origin=origin
|
||||
)
|
||||
if _is_page_context_lost(exc):
|
||||
# #2676: браузер жив, умерла ОДНА страница — relaunch не нужен (стоил бы
|
||||
# ~10-20с и тёплые cookies инстанса). Повторяем на свежей странице, но с
|
||||
# ожиданием `load`: без него повтор попадает в то же окно клиентской
|
||||
# навигации, и «транзиентная» ошибка воспроизводится детерминированно.
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: страница ушла в навигацию (%s), retry fetch-json "
|
||||
"с ожиданием load: %s",
|
||||
provider,
|
||||
type(exc).__name__,
|
||||
url,
|
||||
)
|
||||
return await _fetch_json_once(
|
||||
provider,
|
||||
url,
|
||||
method=method,
|
||||
headers=headers,
|
||||
body=body,
|
||||
origin=origin,
|
||||
wait_for_load=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -1011,6 +1037,7 @@ async def _fetch_json_once(
|
|||
headers: dict,
|
||||
body: object,
|
||||
origin: str,
|
||||
wait_for_load: bool = False,
|
||||
) -> dict:
|
||||
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
|
||||
|
||||
|
|
@ -1029,6 +1056,22 @@ async def _fetch_json_once(
|
|||
await _apply_resource_block(page)
|
||||
await _pace_provider(provider)
|
||||
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
|
||||
if wait_for_load:
|
||||
# Только ретрай после #2676: даём клиентской навигации доиграть до `load`,
|
||||
# иначе повтор попадает ровно в то же окно и падает так же.
|
||||
try:
|
||||
await page.wait_for_load_state( # type: ignore[attr-defined]
|
||||
"load", timeout=FETCH_JSON_LOAD_WAIT_MS
|
||||
)
|
||||
except Exception as exc:
|
||||
# Best-effort: страница может дозагружаться бесконечно (реклама/трекеры).
|
||||
# Не пробрасываем — evaluate ниже сам скажет, готова страница или нет;
|
||||
# молчать нельзя, поэтому пишем в лог.
|
||||
logger.info(
|
||||
"tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть",
|
||||
provider,
|
||||
type(exc).__name__,
|
||||
)
|
||||
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
|
||||
# same-origin scope для fetch), а не отрендеренные listings. Settle-паузы
|
||||
# (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась
|
||||
|
|
@ -1106,6 +1149,23 @@ def _is_browser_crash(exc: BaseException) -> bool:
|
|||
)
|
||||
|
||||
|
||||
# Литерал playwright, а не строка из нашего лога: driver 1.60.0 (в образе сайдкара)
|
||||
# бросает ровно «Execution context was destroyed» / «... , most likely because of a
|
||||
# navigation.» — обе формы начинаются одинаково, поэтому хватает одного маркера.
|
||||
# Проверено grep'ом по playwright/driver/package/lib/coreBundle.js в живом контейнере.
|
||||
_PAGE_CONTEXT_LOST_MARKER = "execution context was destroyed"
|
||||
|
||||
|
||||
def _is_page_context_lost(exc: BaseException) -> bool:
|
||||
"""Страница потеряла JS-контекст (ушла в навигацию между goto и evaluate), #2676.
|
||||
|
||||
НЕ краш браузера: инстанс жив, потеряна одна страница. Поэтому обрабатывается
|
||||
отдельно от _is_browser_crash — relaunch здесь стоил бы ~10-20с и тёплый профиль
|
||||
(cookies/фингерпринт инстанса) ради браузера, с которым всё в порядке.
|
||||
"""
|
||||
return _PAGE_CONTEXT_LOST_MARKER in str(exc).lower()
|
||||
|
||||
|
||||
# ── login handler ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,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.load_waits: list[int] = [] # wait_for_load_state("load", timeout=) — #2676
|
||||
self.closed = 0
|
||||
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
|
||||
self.evaluate = AsyncMock(return_value=evaluate_result)
|
||||
|
|
@ -80,6 +81,9 @@ class _FakePage:
|
|||
async def wait_for_timeout(self, ms: int) -> None:
|
||||
self.waits.append(ms)
|
||||
|
||||
async def wait_for_load_state(self, state: str, timeout: int = 0) -> None:
|
||||
self.load_waits.append(timeout)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed += 1
|
||||
|
||||
|
|
@ -365,3 +369,175 @@ def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch
|
|||
healthy_page.evaluate.assert_awaited_once()
|
||||
assert crashing_page.closed == 1
|
||||
assert healthy_page.closed == 1
|
||||
|
||||
|
||||
# ── гонка «execution context was destroyed» (#2676) ───────────────────────────────
|
||||
|
||||
|
||||
class _FakeSequenceBrowser:
|
||||
"""Отдаёт страницы по очереди: первая попытка ≠ вторая (retry на СВЕЖЕЙ странице)."""
|
||||
|
||||
def __init__(self, pages: list[_FakePage]) -> None:
|
||||
self._pages = list(pages)
|
||||
self.opened = 0
|
||||
|
||||
async def new_page(self) -> _FakePage:
|
||||
page = self._pages[min(self.opened, len(self._pages) - 1)]
|
||||
self.opened += 1
|
||||
return page
|
||||
|
||||
|
||||
def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""Подменяет _relaunch_browser счётчиком — тест падает, если его всё-таки позвали."""
|
||||
calls: list[str] = []
|
||||
|
||||
async def _fake(provider: str) -> None:
|
||||
calls.append(provider)
|
||||
|
||||
monkeypatch.setattr(server, "_relaunch_browser", _fake)
|
||||
return calls
|
||||
|
||||
|
||||
# Обе формы, которые бросает playwright 1.60 (driver coreBundle.js) — короткая и полная.
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"Page.evaluate: Execution context was destroyed, most likely because of a navigation.",
|
||||
"Execution context was destroyed",
|
||||
],
|
||||
)
|
||||
def test_do_fetch_json_retries_on_destroyed_context(
|
||||
monkeypatch: pytest.MonkeyPatch, message: str
|
||||
) -> None:
|
||||
"""#2676: страница ушла в навигацию → повтор на свежей странице, БЕЗ relaunch.
|
||||
|
||||
До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash
|
||||
матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500.
|
||||
"""
|
||||
racing_page = _FakePage({"status": 0, "body": ""})
|
||||
racing_page.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||||
settled_page = _FakePage({"status": 200, "body": '{"recovered": true}'})
|
||||
|
||||
server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page])
|
||||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||
monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242)
|
||||
relaunched = _no_relaunch(monkeypatch)
|
||||
|
||||
result = asyncio.run(
|
||||
server._do_fetch_json(
|
||||
"avito",
|
||||
"https://www.avito.ru/api/x",
|
||||
method="GET",
|
||||
headers={},
|
||||
body=None,
|
||||
origin="https://www.avito.ru/",
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"status": 200, "body": '{"recovered": true}'}
|
||||
# Браузер живой — перезапускать его нельзя (тёплые cookies + ~10-20с).
|
||||
assert relaunched == []
|
||||
# Первая попытка НЕ ждала load (happy-path не удлиняется), повтор — ждал.
|
||||
assert racing_page.load_waits == []
|
||||
assert settled_page.load_waits == [4242]
|
||||
assert racing_page.closed == 1 and settled_page.closed == 1
|
||||
|
||||
|
||||
def test_do_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ожидание load на повторе — best-effort: таймаут не отменяет саму попытку."""
|
||||
racing_page = _FakePage({"status": 0, "body": ""})
|
||||
racing_page.evaluate = AsyncMock(side_effect=RuntimeError("Execution context was destroyed"))
|
||||
settled_page = _FakePage({"status": 200, "body": "ok"})
|
||||
settled_page.wait_for_load_state = AsyncMock( # type: ignore[method-assign]
|
||||
side_effect=TimeoutError("Timeout 15000ms exceeded")
|
||||
)
|
||||
|
||||
server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page])
|
||||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||
_no_relaunch(monkeypatch)
|
||||
|
||||
result = asyncio.run(
|
||||
server._do_fetch_json(
|
||||
"avito",
|
||||
"https://www.avito.ru/api/x",
|
||||
method="GET",
|
||||
headers={},
|
||||
body=None,
|
||||
origin="https://www.avito.ru/",
|
||||
)
|
||||
)
|
||||
assert result == {"status": 200, "body": "ok"}
|
||||
|
||||
|
||||
def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Повтор ровно один: вторая та же ошибка уезжает наверх, а не крутит цикл."""
|
||||
message = "Page.evaluate: Execution context was destroyed"
|
||||
first = _FakePage({"status": 0, "body": ""})
|
||||
first.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||||
second = _FakePage({"status": 0, "body": ""})
|
||||
second.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||||
|
||||
server._browsers["avito"] = _FakeSequenceBrowser([first, second])
|
||||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||
_no_relaunch(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Execution context was destroyed"):
|
||||
asyncio.run(
|
||||
server._do_fetch_json(
|
||||
"avito",
|
||||
"https://www.avito.ru/api/x",
|
||||
method="GET",
|
||||
headers={},
|
||||
body=None,
|
||||
origin="https://www.avito.ru/",
|
||||
)
|
||||
)
|
||||
first.evaluate.assert_awaited_once()
|
||||
second.evaluate.assert_awaited_once()
|
||||
|
||||
|
||||
def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Чужая ошибка НЕ ретраится — повтор невосстановимого жжёт бюджет прогона."""
|
||||
page = _FakePage({"status": 0, "body": ""})
|
||||
page.evaluate = AsyncMock(side_effect=RuntimeError("net::ERR_PROXY_CONNECTION_FAILED"))
|
||||
server._browsers["avito"] = _FakeSequenceBrowser([page])
|
||||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||
_no_relaunch(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError, match="ERR_PROXY_CONNECTION_FAILED"):
|
||||
asyncio.run(
|
||||
server._do_fetch_json(
|
||||
"avito",
|
||||
"https://www.avito.ru/api/x",
|
||||
method="GET",
|
||||
headers={},
|
||||
body=None,
|
||||
origin="https://www.avito.ru/",
|
||||
)
|
||||
)
|
||||
page.evaluate.assert_awaited_once()
|
||||
|
||||
|
||||
def test_fetch_json_handler_500_carries_reason_in_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Причина отказа остаётся в теле 500 — её читает _raise_for_sidecar_status (#2708)."""
|
||||
message = "Execution context was destroyed"
|
||||
first = _FakePage({"status": 0, "body": ""})
|
||||
first.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||||
second = _FakePage({"status": 0, "body": ""})
|
||||
second.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||||
server._browsers["avito"] = _FakeSequenceBrowser([first, second])
|
||||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||
_no_relaunch(monkeypatch)
|
||||
|
||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||
|
||||
response = asyncio.run(
|
||||
server.fetch_json_handler(
|
||||
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||||
)
|
||||
)
|
||||
assert response.status == 500
|
||||
assert "Execution context was destroyed" in _json_body(response)["error"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue