From f218f8558537b60cebbfb010095938097f93932c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 14:35:43 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/browser):=20=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=C2=ABexecution=20context=20destroyed=C2=BB=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D0=BC=D0=B0=D1=8F,=20=D1=80=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B9=20=D0=B1=D0=B5=D0=B7=20relaunch=20(#2676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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 --- tradein-mvp/browser/server.py | 60 ++++++ tradein-mvp/browser/test_server_fetch_json.py | 176 ++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 46c9deb0..b2836430 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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 ────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py index 0af86622..91466ad0 100644 --- a/tradein-mvp/browser/test_server_fetch_json.py +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -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"] -- 2.45.3