"""#2686: статус 'banned' перестаёт смешивать наш сбой с блокировкой площадкой. Замер на проде (2026-08-06): из 115 avito-прогонов со статусом «забанен» 92 (80%) — отказ НАШЕГО браузерного сайдкара («browser unavailable (proxy may be down)»), и только 10 — реальный firewall площадки. По этому статусу приняли решение замедлить avito_full_load_exhaustive более чем вдвое (миграция 206) — основание было ложным. Различитель несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения), а не разбирается из текста ошибки постфактум. Проверяем три звена цепочки: 1. serp.py поднимает AvitoSidecarUnavailableError там и только там, где виноваты мы; 2. pipeline.ban_kind_of_exception переводит тип в диагноз; 3. run_avito_full_load доносит диагноз до mark_banned — и при ЭТОМ сохраняет done_buckets-чекпоинт (побочная функция 'banned', которую нельзя потерять). """ from __future__ import annotations import os from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.avito_exceptions import ( AvitoBlockedError, AvitoRateLimitedError, AvitoSidecarUnavailableError, ) from scraper_kit.orchestration import runs as kit_runs from scraper_kit.orchestration.pipeline import ( ban_kind_of_exception, run_avito_full_load, ) from scraper_kit.providers.avito import serp as serp_module from scraper_kit.providers.avito.serp import AvitoScraper from app.services.scraper_adapters import RealScraperConfig PFX = "scraper_kit.orchestration.pipeline" # Настоящий firewall-интерстициал Авито: _is_firewall_page ловит его по тексту. _FIREWALL_HTML = "Доступ ограничен: проблема с IP" def _sidecar_503() -> httpx.HTTPStatusError: """Ровно то, что прод писал 90 раз с 05.07 по 03.08.""" request = httpx.Request("POST", "http://tradein-browser:3000/fetch") response = httpx.Response( 503, json={"error": "browser unavailable (proxy may be down)"}, request=request ) return httpx.HTTPStatusError("503", request=request, response=response) # ── 1. Место порождения ────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_sidecar_failure_raises_own_infra_subtype() -> None: """503 сайдкара → AvitoSidecarUnavailableError, и он ОСТАЁТСЯ AvitoRateLimitedError. Фальсификация: до правки поднимался голый AvitoRateLimitedError — первый assert падает. Второй assert стережёт совместимость: все существующие `except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт. """ scraper = AvitoScraper(RealScraperConfig()) scraper._browser = AsyncMock() scraper._browser.fetch = AsyncMock(side_effect=_sidecar_503()) with patch.object(serp_module.asyncio, "sleep", AsyncMock()): with pytest.raises(AvitoSidecarUnavailableError) as excinfo: await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) assert isinstance(excinfo.value, AvitoRateLimitedError) @pytest.mark.asyncio async def test_platform_firewall_is_not_our_infra() -> None: """Рендер firewall-страницы → AvitoBlockedError, и это НЕ наш инфра-подтип.""" scraper = AvitoScraper(RealScraperConfig()) scraper._browser = AsyncMock() scraper._browser.fetch = AsyncMock(return_value=_FIREWALL_HTML) scraper._browser.report_ban = MagicMock() # sync-хук, не корутина with pytest.raises(AvitoBlockedError) as excinfo: await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) assert not isinstance(excinfo.value, AvitoSidecarUnavailableError) # ── 2. Перевод типа в диагноз ──────────────────────────────────────────────── @pytest.mark.parametrize( ("exc", "expected"), [ (AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA), (AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM), (AvitoRateLimitedError("HTTP 429"), kit_runs.BAN_KIND_PLATFORM), (RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_UNKNOWN), ], ) def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None: """Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип. Тип, который НИЧЕГО не доказывает, с #2764 даёт 'unknown': `else PLATFORM` здесь был тем же назначенным диагнозом, что и дефолт mark_banned, только уровнем ниже. """ assert ban_kind_of_exception(exc) == expected # ── 3. Диагноз доезжает до строки прогона, чекпоинт цел ────────────────────── class _BanRecorder: """Минимальный двойник scrape_runs: интересуют ban_kind и counters у mark_banned.""" def __init__(self) -> None: self.banned: list[tuple[str, dict[str, Any]]] = [] self.other: list[str] = [] def is_cancelled(self, db: Any, run_id: int) -> bool: return False def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: pass def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: self.other.append("mark_done") def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: self.other.append("mark_failed") def mark_banned( self, db: Any, run_id: int, error: str, counters: dict[str, Any], *, ban_kind: str = kit_runs.BAN_KIND_UNKNOWN, ) -> None: self.banned.append((ban_kind, dict(counters))) def _config() -> SimpleNamespace: return SimpleNamespace( scraper_fetch_mode="curl_cffi", browser_http_endpoint="http://browser.test/fetch", scraper_proxy_url=None, avito_proxy_max_rotations=0, avito_serp_ok_not_banned=True, avito_proxy_rotate_settle_s=0.0, proxy_rotate_attempts=1, proxy_rotate_attempt_timeout_s=1.0, avito_full_load_price_cap=1400, avito_full_load_max_pages=100, avito_full_load_concurrency=1, request_delay_sec=0.0, scraper_skip_seen_today=False, ) def _scraper_that_saves_one_bucket_then(exc: Exception) -> MagicMock: """Fake AvitoScraper: один бакет успешно отдан в on_bucket, затем отказ.""" async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None: on_bucket("2к:0-5m", [MagicMock(source_id="a1")]) raise exc m = MagicMock() m.__aenter__ = AsyncMock(return_value=m) m.__aexit__ = AsyncMock(return_value=None) m.fetch_all_secondary = _fetch m._browser = None return m async def _drive_full_load(exc: Exception) -> _BanRecorder: recorder = _BanRecorder() with ( patch(f"{PFX}.AvitoScraper", return_value=_scraper_that_saves_one_bucket_then(exc)), patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))), patch(f"{PFX}.runs", recorder), ): await run_avito_full_load(MagicMock(), run_id=1, config=_config(), matcher=MagicMock()) return recorder @pytest.mark.asyncio async def test_full_load_sidecar_ban_is_infra_and_keeps_checkpoint() -> None: """Отказ нашего сайдкара → ban_kind='infra', done_buckets сохранён. Фальсификация: до правки mark_banned не принимал ban_kind вовсе — вызов падал бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы 'unknown' (дефолт с #2764; до него — 'platform', ровно то враньё, которое стоило миграции 206). """ recorder = await _drive_full_load( AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)") ) assert len(recorder.banned) == 1 ban_kind, counters = recorder.banned[0] assert ban_kind == kit_runs.BAN_KIND_INFRA assert counters["done_buckets"] == ["2к:0-5m"] @pytest.mark.asyncio async def test_full_load_platform_ban_keeps_the_same_checkpoint() -> None: """Блокировка площадкой → ban_kind='platform', чекпоинт сохраняется ТАК ЖЕ. Второй исход не должен потерять побочную функцию 'banned' при разведении. """ recorder = await _drive_full_load(AvitoBlockedError("Avito SERP firewall — IP banned")) assert len(recorder.banned) == 1 ban_kind, counters = recorder.banned[0] assert ban_kind == kit_runs.BAN_KIND_PLATFORM assert counters["done_buckets"] == ["2к:0-5m"] # ── 4. Диагноз доходит до SQL ──────────────────────────────────────────────── @pytest.mark.parametrize("module_name", ["kit", "app"]) @pytest.mark.parametrize( ("kwargs", "expected"), [({}, "unknown"), ({"ban_kind": "infra"}, "infra")], ) def test_mark_banned_writes_ban_kind( module_name: str, kwargs: dict[str, str], expected: str ) -> None: """Обе копии runs-модуля кладут ban_kind в UPDATE; дефолт — 'unknown' (#2764). Дефолт был 'platform' — и на проде обе строки, помеченные уже в рантайме, взяли именно его: метка выглядела доказательством, не будучи им. """ if module_name == "kit": mod: Any = kit_runs else: from app.services import scrape_runs as app_runs mod = app_runs db = MagicMock() mod.mark_banned(db, 1, "err", {"lots_fetched": 0}, **kwargs) sql, params = db.execute.call_args_list[0][0] # первый UPDATE, не alert-запрос после assert "ban_kind = :ban_kind" in str(sql) assert params["ban_kind"] == expected