From a52b9d07cf4e317c3427fd9045f15cbcba9910d4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 10:18:14 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/browser):=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20evaluate=20=D0=BD=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B6=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B5=20=E2=80=94=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=9F?= =?UTF-8?q?=D0=9E=D0=A1=D0=9B=D0=95=20load=20(#2676)=20(#2721)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/browser/server.py | 146 ++++++++-------- tradein-mvp/browser/test_server_fetch_json.py | 158 +++++++++++++----- 2 files changed, 188 insertions(+), 116 deletions(-) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index b2836430..ed630a90 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -1005,30 +1005,54 @@ 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, - ) + # #2676 обрабатывается ВНУТРИ _fetch_json_once (повтор evaluate на той же + # странице). Повторять здесь, на свежей странице, бесполезно: пересоздание + # воспроизводит ту же первую навигацию origin'а — проверено на проде 06.08, + # обе попытки упали одинаково. raise +# In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), #1917: +# первый XHR после навигации иногда падает до готовности стека. Повтор внутри страницы +# (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). HTTP-статусы (4xx/5xx) — +# это успешный resp, НЕ ретраим (решает caller). +_IN_PAGE_FETCH_JS = """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; +}""" + + +async def _wait_for_load_best_effort(page: object, provider: str) -> None: + """Ждёт `load`, но не даёт ожиданию сорвать саму попытку (реклама/трекеры могут + держать страницу «загружающейся» бесконечно). Таймаут логируется, не пробрасывается. + """ + try: + await page.wait_for_load_state("load", timeout=FETCH_JSON_LOAD_WAIT_MS) # type: ignore[attr-defined] + except Exception as exc: + logger.info( + "tradein-browser[%s]: load не дождались (%s), пробуем evaluate как есть", + provider, + type(exc).__name__, + ) + + async def _fetch_json_once( provider: str, url: str, @@ -1037,7 +1061,6 @@ async def _fetch_json_once( headers: dict, body: object, origin: str, - wait_for_load: bool = False, ) -> dict: """Переходит на origin (same-origin якорь) и выполняет in-page fetch(url). @@ -1056,62 +1079,39 @@ 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) хватает, чтобы страница инициализировалась # перед 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, 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, - "retries": FETCH_JSON_INPAGE_RETRIES, - "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, - }, - ) + payload = { + "url": url, + "method": method, + "headers": headers or {}, + "body": body, + "retries": FETCH_JSON_INPAGE_RETRIES, + "retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, + } + try: + result: dict = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] + except Exception as exc: + if not _is_page_context_lost(exc): + raise + # #2676, живой прод 06.08: страница origin уходит в клиентскую навигацию + # ПОСЛЕ load, поэтому ни settle, ни ожидание load её не опережают, а повтор + # с новой страницей воспроизводит ту же первую навигацию (проверено на + # проде: две попытки подряд — та же ошибка). Навигация при этом ОДНА: + # к моменту исключения она уже случилась и у страницы НОВЫЙ контекст. + # Поэтому повторяем evaluate на ЭТОЙ же странице — стоит миллисекунды. + logger.warning( + "tradein-browser[%s]: контекст страницы пересоздан навигацией, " + "повтор evaluate на той же странице: %s", + provider, + url, + ) + await _wait_for_load_best_effort(page, provider) + await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] + result = await page.evaluate(_IN_PAGE_FETCH_JS, payload) # type: ignore[attr-defined] finally: await page.close() # type: ignore[attr-defined] diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py index 91466ad0..3e841cf4 100644 --- a/tradein-mvp/browser/test_server_fetch_json.py +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -374,17 +374,88 @@ def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch # ── гонка «execution context was destroyed» (#2676) ─────────────────────────────── -class _FakeSequenceBrowser: - """Отдаёт страницы по очереди: первая попытка ≠ вторая (retry на СВЕЖЕЙ странице).""" +def test_fetch_json_retries_evaluate_on_the_same_page(monkeypatch: pytest.MonkeyPatch) -> None: + """#2676 (живой прод 06.08): навигация УЖЕ случилась → повтор evaluate на той же странице. - def __init__(self, pages: list[_FakePage]) -> None: - self._pages = list(pages) - self.opened = 0 + Ключевое отличие от внешнего ретрая: страница НЕ пересоздаётся. Пересоздание + воспроизводит ту же первую навигацию origin'а (проверено на проде: обе попытки + падали одинаково), а после исключения у страницы уже новый, стабильный контекст. + """ + page = _FakePage({"status": 200, "body": "ok"}) + page.evaluate = AsyncMock( + side_effect=[ + RuntimeError("Page.evaluate: Execution context was destroyed, " + "most likely because of a navigation."), + {"status": 200, "body": '{"recovered": true}'}, + ] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242) - async def new_page(self) -> _FakePage: - page = self._pages[min(self.opened, len(self._pages) - 1)] - self.opened += 1 - return page + result = asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + + assert result == {"status": 200, "body": '{"recovered": true}'} + assert page.evaluate.await_count == 2 + # Ни новой страницы, ни повторной навигации — только ожидание конца текущей. + assert browser.opened == 1 + assert page.goto_urls == ["https://www.avito.ru/"] + assert page.load_waits == [4242] + assert page.closed == 1 + + +def test_fetch_json_same_page_retry_happens_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Повтор evaluate ровно один: вторая та же ошибка уезжает наверх (там внешняя ветка).""" + message = "Execution context was destroyed" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="Execution context was destroyed"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + assert page.evaluate.await_count == 2 + assert page.closed == 1 + + +def test_fetch_json_no_same_page_retry_for_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Чужая ошибка evaluate не даёт второго прохода — повтор стоит денег.""" + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError("boom")) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run( + server._fetch_json_once( + "avito", + "https://www.avito.ru/api/x", + method="GET", + headers={}, + body=None, + origin="https://www.avito.ru/", + ) + ) + page.evaluate.assert_awaited_once() def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]: @@ -406,21 +477,21 @@ def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]: "Execution context was destroyed", ], ) -def test_do_fetch_json_retries_on_destroyed_context( +def test_do_fetch_json_recovers_from_destroyed_context( monkeypatch: pytest.MonkeyPatch, message: str ) -> None: - """#2676: страница ушла в навигацию → повтор на свежей странице, БЕЗ relaunch. + """#2676: обе формы сообщения playwright распознаются и переживаются повтором. До правки такая ошибка не попадала ни в одну ветку восстановления (_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]) + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError(message), {"status": 200, "body": '{"recovered": true}'}] + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) - monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242) relaunched = _no_relaunch(monkeypatch) result = asyncio.run( @@ -437,22 +508,21 @@ def test_do_fetch_json_retries_on_destroyed_context( 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 + # И новой страницы тоже нет: повтор идёт по уже перешедшему контексту. + assert browser.opened == 1 + assert page.evaluate.await_count == 2 -def test_do_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None: +def test_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] + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=[RuntimeError("Execution context was destroyed"), {"status": 200, "body": "ok"}] + ) + page.wait_for_load_state = AsyncMock( # type: ignore[method-assign] side_effect=TimeoutError("Timeout 15000ms exceeded") ) - - server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page]) + server._browsers["avito"] = _FakeBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch) @@ -470,14 +540,18 @@ def test_do_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPat 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]) + Свежей страницы тут намеренно НЕТ: пересоздание воспроизводит ту же первую + навигацию origin'а (прод 06.08 — обе попытки упали одинаково), а страховкой + остаётся один повтор всего запроса на стороне клиента (BrowserFetcher). + """ + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock( + side_effect=RuntimeError("Page.evaluate: Execution context was destroyed") + ) + browser = _FakeBrowser(page) + server._browsers["avito"] = browser monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch) @@ -492,15 +566,15 @@ def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.Monk origin="https://www.avito.ru/", ) ) - first.evaluate.assert_awaited_once() - second.evaluate.assert_awaited_once() + assert page.evaluate.await_count == 2 + assert browser.opened == 1 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]) + server._browsers["avito"] = _FakeBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch) @@ -521,11 +595,9 @@ def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.Monkey 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]) + page = _FakePage({"status": 0, "body": ""}) + page.evaluate = AsyncMock(side_effect=RuntimeError(message)) + server._browsers["avito"] = _FakeBrowser(page) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) _no_relaunch(monkeypatch)