All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
Полный проход Яндекса по Москве умер 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
138 lines
5.5 KiB
Python
138 lines
5.5 KiB
Python
"""Ретрай навигации у локального сборщика `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
|