fix(tradein/browser): повтор evaluate на той же странице — гонка случается ПОСЛЕ load (#2676) (#2721)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m8s
Deploy Trade-In / deploy (push) Successful in 1m33s

This commit is contained in:
bot-backend 2026-08-06 10:18:14 +00:00
parent b8afe13ae4
commit a52b9d07cf
2 changed files with 188 additions and 116 deletions

View file

@ -1005,84 +1005,18 @@ 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 обрабатывается ВНУТРИ _fetch_json_once (повтор evaluate на той же
# #2676: браузер жив, умерла ОДНА страница — relaunch не нужен (стоил бы # странице). Повторять здесь, на свежей странице, бесполезно: пересоздание
# ~10-20с и тёплые cookies инстанса). Повторяем на свежей странице, но с # воспроизводит ту же первую навигацию origin'а — проверено на проде 06.08,
# ожиданием `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
async def _fetch_json_once( # In-page retry самого fetch() при СЕТЕВОМ throw (TypeError/"NetworkError"), #1917:
provider: str, # первый XHR после навигации иногда падает до готовности стека. Повтор внутри страницы
url: str, # (~retryDelayMs) дешевле полной ре-навигации (~30-45с/дом). HTTP-статусы (4xx/5xx) —
*, # это успешный resp, НЕ ретраим (решает caller).
method: str, _IN_PAGE_FETCH_JS = """async ({url, method, headers, body, retries, retryDelayMs}) => {
headers: dict,
body: object,
origin: str,
wait_for_load: bool = False,
) -> dict:
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
Зеркалит _fetch_once по жизненному циклу страницы. Навигация идёт на ``origin``
(а не на ``url``), чтобы in-page fetch был same-origin и нёс контекст-cookies +
реальный фингерпринт инстанса. Возвращает {"status": int, "body": str}.
Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся recycle через _relaunch_browser безопасен прямо здесь.
"""
browser = _browsers.get(provider)
assert browser is not None, "browser not launched"
page = await browser.new_page() # type: ignore[attr-defined]
try:
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; let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) { for (let attempt = 0; attempt <= retries; attempt++) {
try { try {
@ -1102,16 +1036,82 @@ async def _fetch_json_once(
} }
} }
throw lastErr; 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,
*,
method: str,
headers: dict,
body: object,
origin: str,
) -> dict:
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
Зеркалит _fetch_once по жизненному циклу страницы. Навигация идёт на ``origin``
(а не на ``url``), чтобы in-page fetch был same-origin и нёс контекст-cookies +
реальный фингерпринт инстанса. Возвращает {"status": int, "body": str}.
Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся recycle через _relaunch_browser безопасен прямо здесь.
"""
browser = _browsers.get(provider)
assert browser is not None, "browser not launched"
page = await browser.new_page() # type: ignore[attr-defined]
try:
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]
# БЕЗ полного 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]
payload = {
"url": url, "url": url,
"method": method, "method": method,
"headers": headers or {}, "headers": headers or {},
"body": body, "body": body,
"retries": FETCH_JSON_INPAGE_RETRIES, "retries": FETCH_JSON_INPAGE_RETRIES,
"retryDelayMs": FETCH_JSON_RETRY_DELAY_MS, "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: finally:
await page.close() # type: ignore[attr-defined] await page.close() # type: ignore[attr-defined]

View file

@ -374,17 +374,88 @@ def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch
# ── гонка «execution context was destroyed» (#2676) ─────────────────────────────── # ── гонка «execution context was destroyed» (#2676) ───────────────────────────────
class _FakeSequenceBrowser: def test_fetch_json_retries_evaluate_on_the_same_page(monkeypatch: pytest.MonkeyPatch) -> None:
"""Отдаёт страницы по очереди: первая попытка ≠ вторая (retry на СВЕЖЕЙ странице).""" """#2676 (живой прод 06.08): навигация УЖЕ случилась → повтор evaluate на той же странице.
def __init__(self, pages: list[_FakePage]) -> None: Ключевое отличие от внешнего ретрая: страница НЕ пересоздаётся. Пересоздание
self._pages = list(pages) воспроизводит ту же первую навигацию origin'а (проверено на проде: обе попытки
self.opened = 0 падали одинаково), а после исключения у страницы уже новый, стабильный контекст.
"""
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: result = asyncio.run(
page = self._pages[min(self.opened, len(self._pages) - 1)] server._fetch_json_once(
self.opened += 1 "avito",
return page "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]: def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]:
@ -406,21 +477,21 @@ def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"Execution context was destroyed", "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 monkeypatch: pytest.MonkeyPatch, message: str
) -> None: ) -> None:
"""#2676: страница ушла в навигацию → повтор на свежей странице, БЕЗ relaunch. """#2676: обе формы сообщения playwright распознаются и переживаются повтором.
До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash
матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500. матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500.
""" """
racing_page = _FakePage({"status": 0, "body": ""}) page = _FakePage({"status": 0, "body": ""})
racing_page.evaluate = AsyncMock(side_effect=RuntimeError(message)) page.evaluate = AsyncMock(
settled_page = _FakePage({"status": 200, "body": '{"recovered": true}'}) side_effect=[RuntimeError(message), {"status": 200, "body": '{"recovered": true}'}]
)
server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page]) browser = _FakeBrowser(page)
server._browsers["avito"] = browser
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242)
relaunched = _no_relaunch(monkeypatch) relaunched = _no_relaunch(monkeypatch)
result = asyncio.run( result = asyncio.run(
@ -437,22 +508,21 @@ def test_do_fetch_json_retries_on_destroyed_context(
assert result == {"status": 200, "body": '{"recovered": true}'} assert result == {"status": 200, "body": '{"recovered": true}'}
# Браузер живой — перезапускать его нельзя (тёплые cookies + ~10-20с). # Браузер живой — перезапускать его нельзя (тёплые cookies + ~10-20с).
assert relaunched == [] assert relaunched == []
# Первая попытка НЕ ждала load (happy-path не удлиняется), повтор — ждал. # И новой страницы тоже нет: повтор идёт по уже перешедшему контексту.
assert racing_page.load_waits == [] assert browser.opened == 1
assert settled_page.load_waits == [4242] assert page.evaluate.await_count == 2
assert racing_page.closed == 1 and settled_page.closed == 1
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: таймаут не отменяет саму попытку.""" """Ожидание load на повторе — best-effort: таймаут не отменяет саму попытку."""
racing_page = _FakePage({"status": 0, "body": ""}) page = _FakePage({"status": 0, "body": ""})
racing_page.evaluate = AsyncMock(side_effect=RuntimeError("Execution context was destroyed")) page.evaluate = AsyncMock(
settled_page = _FakePage({"status": 200, "body": "ok"}) side_effect=[RuntimeError("Execution context was destroyed"), {"status": 200, "body": "ok"}]
settled_page.wait_for_load_state = AsyncMock( # type: ignore[method-assign] )
page.wait_for_load_state = AsyncMock( # type: ignore[method-assign]
side_effect=TimeoutError("Timeout 15000ms exceeded") side_effect=TimeoutError("Timeout 15000ms exceeded")
) )
server._browsers["avito"] = _FakeBrowser(page)
server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page])
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
_no_relaunch(monkeypatch) _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: 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) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
_no_relaunch(monkeypatch) _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/", origin="https://www.avito.ru/",
) )
) )
first.evaluate.assert_awaited_once() assert page.evaluate.await_count == 2
second.evaluate.assert_awaited_once() assert browser.opened == 1
def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None: def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чужая ошибка НЕ ретраится — повтор невосстановимого жжёт бюджет прогона.""" """Чужая ошибка НЕ ретраится — повтор невосстановимого жжёт бюджет прогона."""
page = _FakePage({"status": 0, "body": ""}) page = _FakePage({"status": 0, "body": ""})
page.evaluate = AsyncMock(side_effect=RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")) 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) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
_no_relaunch(monkeypatch) _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: def test_fetch_json_handler_500_carries_reason_in_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""Причина отказа остаётся в теле 500 — её читает _raise_for_sidecar_status (#2708).""" """Причина отказа остаётся в теле 500 — её читает _raise_for_sidecar_status (#2708)."""
message = "Execution context was destroyed" message = "Execution context was destroyed"
first = _FakePage({"status": 0, "body": ""}) page = _FakePage({"status": 0, "body": ""})
first.evaluate = AsyncMock(side_effect=RuntimeError(message)) page.evaluate = AsyncMock(side_effect=RuntimeError(message))
second = _FakePage({"status": 0, "body": ""}) server._browsers["avito"] = _FakeBrowser(page)
second.evaluate = AsyncMock(side_effect=RuntimeError(message))
server._browsers["avito"] = _FakeSequenceBrowser([first, second])
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
_no_relaunch(monkeypatch) _no_relaunch(monkeypatch)