fix(tradein/browser): гонка «execution context destroyed» — восстановимая, ретрай без relaunch (#2676) #2716
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"))
|
FETCH_JSON_INPAGE_RETRIES: int = int(os.environ.get("FETCH_JSON_INPAGE_RETRIES", "1"))
|
||||||
# Пауза между in-page попытками fetch(), мс.
|
# Пауза между in-page попытками fetch(), мс.
|
||||||
FETCH_JSON_RETRY_DELAY_MS: int = int(os.environ.get("FETCH_JSON_RETRY_DELAY_MS", "800"))
|
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" — фолбэк для всех прочих хостов (один общий
|
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
|
||||||
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
|
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
|
||||||
|
|
@ -1000,6 +1005,27 @@ async def _do_fetch_json(
|
||||||
return await _fetch_json_once(
|
return await _fetch_json_once(
|
||||||
provider, url, method=method, headers=headers, body=body, origin=origin
|
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
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1011,6 +1037,7 @@ async def _fetch_json_once(
|
||||||
headers: dict,
|
headers: dict,
|
||||||
body: object,
|
body: object,
|
||||||
origin: str,
|
origin: str,
|
||||||
|
wait_for_load: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
|
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
|
||||||
|
|
||||||
|
|
@ -1029,6 +1056,22 @@ async def _fetch_json_once(
|
||||||
await _apply_resource_block(page)
|
await _apply_resource_block(page)
|
||||||
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]
|
||||||
|
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 +
|
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
|
||||||
# same-origin scope для fetch), а не отрендеренные listings. Settle-паузы
|
# same-origin scope для fetch), а не отрендеренные listings. Settle-паузы
|
||||||
# (#1917, FETCH_JSON_SETTLE_MS) хватает, чтобы страница инициализировалась
|
# (#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 ──────────────────────────────────────────────────────────────
|
# ── login handler ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,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.waits: list[int] = [] # записанные wait_for_timeout(ms) — settle-проверка #1917
|
||||||
|
self.load_waits: list[int] = [] # wait_for_load_state("load", timeout=) — #2676
|
||||||
self.closed = 0
|
self.closed = 0
|
||||||
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
|
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
|
||||||
self.evaluate = AsyncMock(return_value=evaluate_result)
|
self.evaluate = AsyncMock(return_value=evaluate_result)
|
||||||
|
|
@ -80,6 +81,9 @@ class _FakePage:
|
||||||
async def wait_for_timeout(self, ms: int) -> None:
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
self.waits.append(ms)
|
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:
|
async def close(self) -> None:
|
||||||
self.closed += 1
|
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()
|
healthy_page.evaluate.assert_awaited_once()
|
||||||
assert crashing_page.closed == 1
|
assert crashing_page.closed == 1
|
||||||
assert healthy_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