Compare commits
No commits in common. "b14f21aa785cc5a2c707f67264d2f81d5cd0ac91" and "ba17c868d4a6d6ed584cf889ec8baf53134047cf" have entirely different histories.
b14f21aa78
...
ba17c868d4
2 changed files with 4 additions and 191 deletions
|
|
@ -1,138 +0,0 @@
|
||||||
"""Ретрай навигации у локального сборщика `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,28 +151,6 @@ _POW_MARKERS = ("startpow", "доступ ограничен: проверка
|
||||||
# Заполняется при входе в Loader.__aenter__ (playwright импортируется лениво,
|
# Заполняется при входе в Loader.__aenter__ (playwright импортируется лениво,
|
||||||
# чтобы --help работал без установленного пакета).
|
# чтобы --help работал без установленного пакета).
|
||||||
PlaywrightTimeoutError: type[BaseException] = TimeoutError
|
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).
|
# Через столько загрузок вкладка сборщика пересоздаётся (см. _recycle_if_needed).
|
||||||
|
|
@ -650,13 +628,11 @@ class Loader:
|
||||||
self._loads_on_page = 0
|
self._loads_on_page = 0
|
||||||
|
|
||||||
async def __aenter__(self) -> "Loader":
|
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 async_playwright
|
||||||
from playwright.async_api import TimeoutError as _PwTimeout
|
from playwright.async_api import TimeoutError as _PwTimeout
|
||||||
|
|
||||||
global PlaywrightTimeoutError, PlaywrightError
|
global PlaywrightTimeoutError
|
||||||
PlaywrightTimeoutError = _PwTimeout
|
PlaywrightTimeoutError = _PwTimeout
|
||||||
PlaywrightError = _PwError
|
|
||||||
|
|
||||||
# AVITO_CDP — исторически названо под первую платформу, но это адрес
|
# AVITO_CDP — исторически названо под первую платформу, но это адрес
|
||||||
# браузера владельца, а не площадки: используется для обеих платформ.
|
# браузера владельца, а не площадки: используется для обеих платформ.
|
||||||
|
|
@ -744,7 +720,7 @@ class Loader:
|
||||||
except Exception: # noqa: BLE001 — старая вкладка могла уже умереть
|
except Exception: # noqa: BLE001 — старая вкладка могла уже умереть
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _goto(self, url: str, attempts: int = 3, net_attempts: int = 5):
|
async def _goto(self, url: str, attempts: int = 3):
|
||||||
"""goto с ограниченным ретраем на таймаут навигации.
|
"""goto с ограниченным ретраем на таймаут навигации.
|
||||||
|
|
||||||
Авито изредка держит соединение до упора и goto падает по timeout. Это
|
Авито изредка держит соединение до упора и goto падает по timeout. Это
|
||||||
|
|
@ -754,37 +730,12 @@ class Loader:
|
||||||
повтором пробуем прочитать то, что в документе, и прогнать через _guard,
|
повтором пробуем прочитать то, что в документе, и прогнать через _guard,
|
||||||
чтобы настоящий блок остановил прогон с правильной причиной; исчерпали
|
чтобы настоящий блок остановил прогон с правильной причиной; исчерпали
|
||||||
попытки — жёсткий стоп с причиной nav_timeout.
|
попытки — жёсткий стоп с причиной nav_timeout.
|
||||||
|
|
||||||
Отдельно — сетевые отказы самой машины (`_TRANSIENT_NET_ERRORS`). Их
|
|
||||||
считаем своим счётчиком и ждём дольше: обрыв сети длится минуты, а не
|
|
||||||
секунды, и площадка тут ни при чём. Именно на таком отказе 12.09 умер
|
|
||||||
восьмичасовой проход Яндекса. Всё, что не в списке, поднимается как
|
|
||||||
раньше — молча глотать незнакомую ошибку навигации нельзя, тихий отказ
|
|
||||||
выглядит ровно так же.
|
|
||||||
"""
|
"""
|
||||||
net_failures = 0
|
for i in range(attempts):
|
||||||
i = -1
|
|
||||||
while True:
|
|
||||||
i += 1
|
|
||||||
try:
|
try:
|
||||||
return await self._page.goto(url, wait_until="domcontentloaded",
|
return await self._page.goto(url, wait_until="domcontentloaded",
|
||||||
timeout=90_000)
|
timeout=90_000)
|
||||||
except PlaywrightError as exc: # noqa: PERF203
|
except PlaywrightTimeoutError:
|
||||||
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:
|
try:
|
||||||
partial = await self._page.content()
|
partial = await self._page.content()
|
||||||
except Exception: # noqa: BLE001 — документа может не быть вовсе
|
except Exception: # noqa: BLE001 — документа может не быть вовсе
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue