gendesign/tradein-mvp/backend/tests/test_msk_collector_goto_retry.py
bot-backend 1bcc922e80
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
fix(msk-collector): сетевой обрыв на машине больше не убивает многочасовой проход
Полный проход Яндекса по Москве умер 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
2026-09-12 02:35:42 +03:00

138 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Ретрай навигации у локального сборщика `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