From 1bcc922e8028195e35a60bcbfd8a429d5cce80bc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 02:35:42 +0300 Subject: [PATCH] =?UTF-8?q?fix(msk-collector):=20=D1=81=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B0=D1=88=D0=B8=D0=BD=D0=B5=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=83=D0=B1=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D1=85?= =?UTF-8?q?=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный проход Яндекса по Москве умер 12.09 на восьмом часу: `Page.goto: net::ERR_NETWORK_CHANGED`. Площадка была ни при чём — сразу после и realty.yandex.ru, и git.gendsgn.ru отвечали 200, а в ту же минуту по DNS отвалился и MCP-сервер ошибок. Моргнула сеть на самой машине. Ретрай в `_goto` ловил только `PlaywrightTimeoutError`, поэтому обычная сетевая ошибка уходила наверх и роняла прогон целиком. Сетевые отказы Chromium вынесены в `_TRANSIENT_NET_ERRORS` и считаются СВОИМ счётчиком: пять попыток с нарастающим ожиданием 15/30/45/60/120 с. Бюджет попыток по таймауту при этом не тратится — обрыв сети длится минуты, а таймаут навигации это совсем другой симптом. Исчерпали — стоп с причиной `nav_network`, прогон продолжается тем же batch-id с --resume. Всё, что не в списке, по-прежнему поднимается: тихий отказ площадки выглядит ровно так же, и молча проглоченный он превращается в пустой прогон. Тестов у сборщика не было вообще, при том что он уже дважды ронял многочасовой прогон на ошибке, которая отказом площадки не была. Заведён первый файл: он подгружает скрипт по пути (нет на месте — skip, не красный) и караулит границу «наше или ихнее» — распознавание сетевых ошибок, переживание короткого обрыва, остановка при длинном с правильной причиной, проброс незнакомой ошибки и неизменность прежнего пути по таймауту. Полный сьют бэкенда — 5902 passed, 37 skipped; ruff чист. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh --- .../tests/test_msk_collector_goto_retry.py | 138 ++++++++++++++++++ .../scripts/local-avito-msk/collect.py | 57 +++++++- 2 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_msk_collector_goto_retry.py diff --git a/tradein-mvp/backend/tests/test_msk_collector_goto_retry.py b/tradein-mvp/backend/tests/test_msk_collector_goto_retry.py new file mode 100644 index 00000000..34a5f537 --- /dev/null +++ b/tradein-mvp/backend/tests/test_msk_collector_goto_retry.py @@ -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 diff --git a/tradein-mvp/scripts/local-avito-msk/collect.py b/tradein-mvp/scripts/local-avito-msk/collect.py index 1a349360..a0132cb1 100644 --- a/tradein-mvp/scripts/local-avito-msk/collect.py +++ b/tradein-mvp/scripts/local-avito-msk/collect.py @@ -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 — документа может не быть вовсе -- 2.45.3