Merge pull request 'Сборщик: обрыв сети на машине не должен убивать многочасовой проход' (#3455) from fix/msk-collector-net-retry into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 1m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s

This commit is contained in:
bot-backend 2026-09-11 23:42:08 +00:00
commit b14f21aa78
2 changed files with 191 additions and 4 deletions

View 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

View file

@ -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 — документа может не быть вовсе