Сборщик: обрыв сети на машине не должен убивать многочасовой проход #3455
2 changed files with 191 additions and 4 deletions
138
tradein-mvp/backend/tests/test_msk_collector_goto_retry.py
Normal file
138
tradein-mvp/backend/tests/test_msk_collector_goto_retry.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Ретрай навигации у локального сборщика `scripts/local-avito-msk/collect.py`.
|
||||
|
||||
Сборщик лежит вне пакета бэкенда и до сих пор не имел тестов вообще — при том
|
||||
что уже дважды ронял многочасовой прогон на ошибке, которая отказом площадки не
|
||||
была. Поэтому файл подгружается по пути; нет его на месте (урезанный чекаут) —
|
||||
тест пропускается, а не краснеет.
|
||||
|
||||
Караулим ровно границу «наше или ихнее»:
|
||||
* сетевой сбой САМОЙ МАШИНЫ (`net::ERR_*` из списка) — ждать и повторять;
|
||||
* таймаут навигации — прежние три попытки и `nav_timeout`;
|
||||
* любая другая ошибка навигации — поднимать, потому что тихий отказ площадки
|
||||
выглядит точно так же, а молча проглоченный он превращается в пустой прогон.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
_COLLECTOR = (
|
||||
Path(__file__).resolve().parents[2] / "scripts" / "local-avito-msk" / "collect.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def collect() -> Any:
|
||||
if not _COLLECTOR.exists(): # pragma: no cover — урезанный чекаут
|
||||
pytest.skip(f"нет {_COLLECTOR}")
|
||||
# Сборщик тянет scraper_kit, а тот — Settings с обязательным database_url.
|
||||
# Ставим тот же фиктивный DSN, что и соседние тесты: в сеть и в БД этот
|
||||
# модуль не ходит, значение нужно только чтобы импорт состоялся.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
spec = importlib.util.spec_from_file_location("msk_collect_under_test", _COLLECTOR)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module # dataclass резолвит модуль через sys.modules
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class _PwError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _PwTimeoutError(_PwError):
|
||||
pass
|
||||
|
||||
|
||||
class _Page:
|
||||
"""Отдаёт заготовленный сценарий: исключение или успешный ответ."""
|
||||
|
||||
def __init__(self, script: list[BaseException | None]) -> None:
|
||||
self.script = list(script)
|
||||
self.calls = 0
|
||||
|
||||
async def goto(self, url: str, **_kw: Any) -> str:
|
||||
self.calls += 1
|
||||
exc = self.script.pop(0)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return "RESP"
|
||||
|
||||
async def content(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def loader(collect: Any, monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
monkeypatch.setattr(collect, "PlaywrightError", _PwError)
|
||||
monkeypatch.setattr(collect, "PlaywrightTimeoutError", _PwTimeoutError)
|
||||
|
||||
async def _no_sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(collect.asyncio, "sleep", _no_sleep)
|
||||
|
||||
def _make(page: _Page) -> Any:
|
||||
obj = collect.Loader.__new__(collect.Loader)
|
||||
obj._page = page
|
||||
obj._adapter = None
|
||||
return obj
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
["Page.goto: net::ERR_NETWORK_CHANGED at https://x", "net::ERR_CONNECTION_RESET"],
|
||||
)
|
||||
def test_transient_network_errors_are_recognised(collect: Any, message: str) -> None:
|
||||
assert collect._is_transient_net_error(Exception(message))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
["Page.goto: net::ERR_CERT_AUTHORITY_INVALID", "Доступ ограничен", ""],
|
||||
)
|
||||
def test_other_errors_are_not_network(collect: Any, message: str) -> None:
|
||||
assert not collect._is_transient_net_error(Exception(message))
|
||||
|
||||
|
||||
def test_network_blip_is_waited_out(loader: Any) -> None:
|
||||
"""Два обрыва подряд — не повод терять прогон: третья попытка отдаёт ответ."""
|
||||
page = _Page(
|
||||
[_PwError("net::ERR_NETWORK_CHANGED"), _PwError("net::ERR_NETWORK_CHANGED"), None]
|
||||
)
|
||||
assert asyncio.run(loader(page)._goto("u")) == "RESP"
|
||||
assert page.calls == 3
|
||||
|
||||
|
||||
def test_network_gone_for_good_stops_with_its_own_reason(collect: Any, loader: Any) -> None:
|
||||
page = _Page([_PwError("net::ERR_NETWORK_CHANGED")] * 6)
|
||||
with pytest.raises(collect.Blocked) as caught:
|
||||
asyncio.run(loader(page)._goto("u"))
|
||||
assert str(caught.value) == "nav_network"
|
||||
assert page.calls == 5
|
||||
|
||||
|
||||
def test_unknown_navigation_error_is_raised(loader: Any) -> None:
|
||||
"""Незнакомую ошибку глотать нельзя — под ней прячется настоящий отказ."""
|
||||
page = _Page([_PwError("net::ERR_CERT_AUTHORITY_INVALID")])
|
||||
with pytest.raises(_PwError):
|
||||
asyncio.run(loader(page)._goto("u"))
|
||||
|
||||
|
||||
def test_timeout_path_is_unchanged(collect: Any, loader: Any) -> None:
|
||||
"""Сетевой счётчик отдельный: бюджет попыток по таймауту остался прежним."""
|
||||
page = _Page([_PwTimeoutError("t")] * 4)
|
||||
with pytest.raises(collect.Blocked) as caught:
|
||||
asyncio.run(loader(page)._goto("u"))
|
||||
assert str(caught.value) == "nav_timeout"
|
||||
assert page.calls == 3
|
||||
|
|
@ -151,6 +151,28 @@ _POW_MARKERS = ("startpow", "доступ ограничен: проверка
|
|||
# Заполняется при входе в Loader.__aenter__ (playwright импортируется лениво,
|
||||
# чтобы --help работал без установленного пакета).
|
||||
PlaywrightTimeoutError: type[BaseException] = TimeoutError
|
||||
PlaywrightError: type[BaseException] = Exception
|
||||
|
||||
# Сетевые отказы Chromium, которые НЕ означают отказ площадки: у машины моргнула
|
||||
# сеть, сменился интерфейс, оборвалось соединение. Замер 12.09: полный проход
|
||||
# Яндекса умер на восьмом часу на `net::ERR_NETWORK_CHANGED`, потеряв живой
|
||||
# прогон целиком — площадка при этом отвечала 200 сразу после. Такие ошибки
|
||||
# лечатся ожиданием, а не остановкой; всё остальное по-прежнему поднимается.
|
||||
_TRANSIENT_NET_ERRORS = (
|
||||
"net::ERR_NETWORK_CHANGED",
|
||||
"net::ERR_INTERNET_DISCONNECTED",
|
||||
"net::ERR_NAME_NOT_RESOLVED",
|
||||
"net::ERR_CONNECTION_RESET",
|
||||
"net::ERR_CONNECTION_CLOSED",
|
||||
"net::ERR_CONNECTION_ABORTED",
|
||||
"net::ERR_ADDRESS_UNREACHABLE",
|
||||
"net::ERR_NETWORK_IO_SUSPENDED",
|
||||
)
|
||||
|
||||
|
||||
def _is_transient_net_error(exc: BaseException) -> bool:
|
||||
text = str(exc)
|
||||
return any(marker in text for marker in _TRANSIENT_NET_ERRORS)
|
||||
|
||||
|
||||
# Через столько загрузок вкладка сборщика пересоздаётся (см. _recycle_if_needed).
|
||||
|
|
@ -628,11 +650,13 @@ class Loader:
|
|||
self._loads_on_page = 0
|
||||
|
||||
async def __aenter__(self) -> "Loader":
|
||||
from playwright.async_api import Error as _PwError
|
||||
from playwright.async_api import async_playwright
|
||||
from playwright.async_api import TimeoutError as _PwTimeout
|
||||
|
||||
global PlaywrightTimeoutError
|
||||
global PlaywrightTimeoutError, PlaywrightError
|
||||
PlaywrightTimeoutError = _PwTimeout
|
||||
PlaywrightError = _PwError
|
||||
|
||||
# AVITO_CDP — исторически названо под первую платформу, но это адрес
|
||||
# браузера владельца, а не площадки: используется для обеих платформ.
|
||||
|
|
@ -720,7 +744,7 @@ class Loader:
|
|||
except Exception: # noqa: BLE001 — старая вкладка могла уже умереть
|
||||
pass
|
||||
|
||||
async def _goto(self, url: str, attempts: int = 3):
|
||||
async def _goto(self, url: str, attempts: int = 3, net_attempts: int = 5):
|
||||
"""goto с ограниченным ретраем на таймаут навигации.
|
||||
|
||||
Авито изредка держит соединение до упора и goto падает по timeout. Это
|
||||
|
|
@ -730,12 +754,37 @@ class Loader:
|
|||
повтором пробуем прочитать то, что в документе, и прогнать через _guard,
|
||||
чтобы настоящий блок остановил прогон с правильной причиной; исчерпали
|
||||
попытки — жёсткий стоп с причиной nav_timeout.
|
||||
|
||||
Отдельно — сетевые отказы самой машины (`_TRANSIENT_NET_ERRORS`). Их
|
||||
считаем своим счётчиком и ждём дольше: обрыв сети длится минуты, а не
|
||||
секунды, и площадка тут ни при чём. Именно на таком отказе 12.09 умер
|
||||
восьмичасовой проход Яндекса. Всё, что не в списке, поднимается как
|
||||
раньше — молча глотать незнакомую ошибку навигации нельзя, тихий отказ
|
||||
выглядит ровно так же.
|
||||
"""
|
||||
for i in range(attempts):
|
||||
net_failures = 0
|
||||
i = -1
|
||||
while True:
|
||||
i += 1
|
||||
try:
|
||||
return await self._page.goto(url, wait_until="domcontentloaded",
|
||||
timeout=90_000)
|
||||
except PlaywrightTimeoutError:
|
||||
except PlaywrightError as exc: # noqa: PERF203
|
||||
if not isinstance(exc, PlaywrightTimeoutError):
|
||||
if not _is_transient_net_error(exc):
|
||||
raise
|
||||
net_failures += 1
|
||||
if net_failures >= net_attempts:
|
||||
raise Blocked("nav_network") from None
|
||||
wait_s = min(120.0, 15.0 * net_failures)
|
||||
print(
|
||||
f" сеть машины отвалилась ({str(exc).splitlines()[0][:80]}), "
|
||||
f"ждём {wait_s:.0f} с, попытка {net_failures + 1}/{net_attempts}",
|
||||
flush=True,
|
||||
)
|
||||
await asyncio.sleep(wait_s)
|
||||
i -= 1 # сетевой сбой не тратит бюджет попыток по таймауту
|
||||
continue
|
||||
try:
|
||||
partial = await self._page.content()
|
||||
except Exception: # noqa: BLE001 — документа может не быть вовсе
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue