"""#3118: run_domclick_city_sweep не отличал исчерпание пула прокси от отказа сбора. `NoProxyAvailableError` поднимается из `BrowserFetcher.__aenter__` (`_acquire_lease`) ДО первого HTTP-запроса, когда в пуле не осталось свободных узлов — это НАША инфраструктура, не блокировка площадкой. У run_avito_full_load/run_cian_full_load/ run_yandex_full_load есть выделенный `except NoProxyAvailableError` → mark_banned с ban_kind_of_exception() (даёт BAN_KIND_INFRA). У домклик-свипа его не было: исключение проваливалось в общий `except Exception` внутри SERP-фазы, `_scraper_ref` оставался пустым (scraper не успел создаться), и честный статус ниже видел «0 лотов + errors>0» → `mark_failed("fetch errors — 0 listings")` с `ban_kind=NULL`. Прод-факт: run 5023 (27.08) умер за 51 мс, errors_count=1, ban_kind=NULL — неотличимо от честного отказа сбора, хотя площадка вообще не была затронута. На старом коде (до правки) этот тест падает: mark_failed вместо mark_banned, ban_kind остаётся None. См. `test_2687_domclick_ban_kind.py` — тот же _BanKindRecorder паттерн, для соседнего исхода (QRATOR-блок → platform). """ from __future__ import annotations import os from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.orchestration.pipeline import run_domclick_city_sweep from scraper_kit.orchestration.runs import BAN_KIND_INFRA from scraper_kit.proxy_errors import NoProxyAvailableError PFX = "scraper_kit.orchestration.pipeline" class _BanKindRecorder: """Ловит исход (done/failed/banned) и ban_kind, переданный в mark_banned.""" def __init__(self) -> None: self.ban_kind: str | None = None self.marked: list[str] = [] self.errors: 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: return None def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: self.marked.append("done") def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: self.marked.append("failed") self.errors.append(error) def mark_banned( self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any ) -> None: self.marked.append("banned") self.errors.append(error) self.ban_kind = kw.get("ban_kind") async def _drive_no_proxy() -> _BanKindRecorder: """DomClickScraper.__aenter__ поднимает NoProxyAvailableError ДО первого фетча — пул опустел. fetch_city не вызывается вовсе (async with не входит в блок).""" recorder = _BanKindRecorder() scraper = MagicMock() scraper.__aenter__ = AsyncMock(side_effect=NoProxyAvailableError("domclick")) scraper.__aexit__ = AsyncMock(return_value=None) scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)]) with ( patch(f"{PFX}.DomClickScraper", return_value=scraper), patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))), patch(f"{PFX}.runs", recorder), ): await run_domclick_city_sweep( MagicMock(), config=SimpleNamespace(browser_http_endpoint="http://x:9000"), matcher=MagicMock(), run_id=5023, city_id=4, pages=1, request_delay_sec=0.0, ) return recorder async def test_no_proxy_marks_infra_ban_not_failed() -> None: """Пул прокси пуст → banned/infra, НЕ failed. fetch_city не должен звонить площадке.""" recorder = await _drive_no_proxy() assert recorder.marked == ["banned"], ( f"ожидали ровно один исход 'banned', получили {recorder.marked} — " "исчерпание пула прокси провалилось в общий except и было объявлено " "отказом сбора (mark_failed), а не инфраструктурным баном" ) assert recorder.ban_kind == BAN_KIND_INFRA, ( f"ban_kind={recorder.ban_kind!r}, ожидали {BAN_KIND_INFRA!r} — " "отказ пула прокси должен классифицироваться как НАША инфраструктура, " "не как блокировка площадкой и не как NULL/unknown" ) assert recorder.errors and "no proxy" in recorder.errors[-1].lower(), ( f"error-сообщение {recorder.errors!r} не упоминает отказ пула — " "диагностика в scrape_runs.error должна называть причину, а не общий текст" )