diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index f44fc756..7b42fd0e 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -169,7 +169,10 @@ async def _execute_cian_backfill( runs_mod.mark_failed( db, run_id, "пул прокси пуст — к площадке не ходили (#3197)", counters ) - logger.error( + # INFO, как у соседей (avito_detail_backfill.py:1050, domclick:626): причина + # уже записана в mark_failed + counters.no_proxy_stop, а ERROR на финальной + # строке ставил в один разряд с падением задачи (logger.exception ниже). + logger.info( "scheduler: cian_history_backfill run_id=%d СТОП (пул пуст) — " "listings=%d/%d houses=%d/%d %.1fs", run_id, diff --git a/tradein-mvp/backend/tests/test_3197_cian_history_proxy_pool_wiring.py b/tradein-mvp/backend/tests/test_3197_cian_history_proxy_pool_wiring.py index 19a7b1dd..7b35a982 100644 --- a/tradein-mvp/backend/tests/test_3197_cian_history_proxy_pool_wiring.py +++ b/tradein-mvp/backend/tests/test_3197_cian_history_proxy_pool_wiring.py @@ -9,9 +9,12 @@ env/direct» (#2616) на этом пути был мёртв, потому чт до конструктора не доезжал. Соседи уже починены: domclick (#3197 ч.1, см. test_3197_domclick_proxy_pool_wiring.py) и house_imv/avito (#2698). -Второй тест — про то, чем оживший отказ оборачивается в прогоне: пустой пул +Остальные тесты — про то, чем оживший отказ оборачивается в прогоне: пустой пул поднимается ДО запроса, поэтому следующее объявление упрётся ровно в то же самое, и -батч обязан оборваться на первом, а не крутить весь список. +батч обязан оборваться на первом, а не крутить весь список. Подделка у них — на уровне +`BrowserFetcher.fetch()`, НАСТОЯЩИЙ `fetch_detail`: его браузерная ветка глотала любое +исключение и возвращала None, поэтому стоп в задаче не срабатывал ни разу, а прогон, +не ходивший к площадке, финализировался как 'done' (#3197, ревью PR #3382). Сеть/БД/камуфокс замоканы; в сеть тест не ходит. """ @@ -19,13 +22,16 @@ test_3197_domclick_proxy_pool_wiring.py) и house_imv/avito (#2698). from __future__ import annotations import os +from contextlib import ExitStack from types import SimpleNamespace from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +import httpx import pytest +from scraper_kit.providers.cian.detail import fetch_detail from scraper_kit.proxy_errors import NoProxyAvailableError from app.tasks import cian_history_backfill as chb @@ -52,6 +58,40 @@ class _CapturingFetcher: return None +class _EmptyPoolFetcher: + """BrowserFetcher, у которого пул опустел: `fetch()` поднимает `NoProxyAvailableError`. + + Ровно как в проде (`browser_fetcher.py:682`): отказ поднимается ДО POST в сайдкар, + запроса к площадке не было. Подделка кончается здесь — `fetch_detail` настоящий. + """ + + calls: ClassVar[list[str]] = [] + + def __init__(self, **_kwargs: Any) -> None: + self.last_response_status: int | None = None + + async def __aenter__(self) -> _EmptyPoolFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def fetch(self, url: str, **_kwargs: Any) -> str: + _EmptyPoolFetcher.calls.append(url) + raise NoProxyAvailableError("cian") + + +class _RaisingFetcher: + """Фетчер с заданным исключением — для прямых тестов контракта `fetch_detail`.""" + + def __init__(self, exc: BaseException) -> None: + self.exc = exc + self.last_response_status: int | None = None + + async def fetch(self, _url: str, **_kwargs: Any) -> str: + raise self.exc + + def _mock_db(n_rows: int) -> MagicMock: rows = [ {"id": i + 1, "source_url": f"https://ekb.cian.ru/sale/flat/{i + 1}/"} @@ -64,14 +104,21 @@ def _mock_db(n_rows: int) -> MagicMock: return db -async def _run_listings(db: MagicMock, fetch: Any) -> chb.CianBackfillResult: - with ( - patch.object(chb, "BrowserFetcher", _CapturingFetcher), - patch(_FETCH, fetch), +async def _run_listings( + db: MagicMock, fetch: Any = None, fetcher: type = _CapturingFetcher +) -> chb.CianBackfillResult: + """Прогон listings-блока. `fetch=None` ⇒ `fetch_detail` НЕ подменяется (рабочий тракт).""" + patches = [ + patch.object(chb, "BrowserFetcher", fetcher), patch(_SAVE, return_value=True), patch(_SLEEP, new_callable=AsyncMock), patch(_DELAY, return_value=0.0), - ): + ] + if fetch is not None: + patches.append(patch(_FETCH, fetch)) + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) return await chb.backfill_cian_history( db, batch_size=10, do_listings=True, do_houses=False, do_valuations=False ) @@ -105,23 +152,27 @@ async def test_browser_fetcher_gets_proxy_pool_wiring( async def test_empty_pool_stops_the_batch_on_first_listing() -> None: - """«Пул пуст» на первом объявлении обрывает батч, а не крутит весь список.""" - _CapturingFetcher.captured = {} + """«Пул пуст» на первом объявлении обрывает батч, а не крутит весь список. - def _raise_wrapped(*_a: object, **_kw: object) -> None: - # Ровно как в проде: провайдер заворачивает сбой фетча в своё исключение, и - # «пул пуст» приезжает наверх под видом отказа площадки. - try: - raise NoProxyAvailableError("cian") - except NoProxyAvailableError as exc: - raise RuntimeError("cian detail fetch failed") from exc + Замокано: БД (`_mock_db`), `save_detail_enrichment`, `asyncio.sleep`, задержка и + сам `BrowserFetcher` — подделка кончается на `fetch()`, который поднимает + `NoProxyAvailableError` вместо HTTP-запроса в сайдкар. - fetch = AsyncMock(side_effect=_raise_wrapped) - result = await _run_listings(_mock_db(3), fetch) + НЕ замокан `fetch_detail` — прогон идёт через настоящую браузерную ветку + `providers/cian/detail.py`. Прежняя редакция подменяла `fetch_detail` моком, + который сам поднимал исключение, и была зелёной по построению: живая функция + исключение ГЛОТАЛА и возвращала None, поэтому стоп не срабатывал ни разу + (ревью PR #3382 — прогон, не ходивший к площадке, уходил в 'done'). + """ + _EmptyPoolFetcher.calls = [] + + result = await _run_listings(_mock_db(3), fetcher=_EmptyPoolFetcher) # Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате # должно означать «прошли 3 строки вместо 1», а не «поля нет». - assert fetch.await_count == 1, "к площадке ходили только один раз — пул пуст с первого" + assert _EmptyPoolFetcher.calls == ["https://ekb.cian.ru/sale/flat/1/"], ( + "к сайдкару ходили ровно один раз — пул пуст с первого объявления" + ) assert result.listings_processed == 1, "батч обязан оборваться, а не пройти все 3 строки" assert getattr(result, "no_proxy_stop", False) is True # Отказ НАШЕЙ стороны не должен маскироваться под бан площадки (иначе прогон уйдёт @@ -130,6 +181,30 @@ async def test_empty_pool_stops_the_batch_on_first_listing() -> None: assert result.listings_blocked == 0 +async def test_fetch_detail_propagates_empty_pool() -> None: + """`fetch_detail` пробрасывает `NoProxyAvailableError`, а не гасит его в None. + + Прямой контракт общей функции: на None вызывающий отвечает «не разобрали» и идёт + к следующему объявлению — то есть глотание здесь отменяет стоп у ВСЕХ вызывающих + браузерного пути. Соседний `app/services/cian_price_history.py:143` того же типа + ждёт отдельным `except`. + """ + fetcher = _RaisingFetcher(NoProxyAvailableError("cian")) + with pytest.raises(NoProxyAvailableError): + await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type] + + +async def test_fetch_detail_still_returns_none_on_transport_error() -> None: + """Прежний контракт цел: обычный сбой фетча по-прежнему None, а не исключение. + + Иначе «пробрасываем пустой пул» превратилось бы в «пробрасываем всё», и каждый + таймаут сайдкара рвал бы батч вместо одной пропущенной карточки. + """ + fetcher = _RaisingFetcher(httpx.ConnectTimeout("sidecar timeout")) + got = await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type] + assert got is None + + async def test_run_marked_failed_with_no_proxy_stop_counter() -> None: """Прогон с пустым пулом финализируется как failed + counters.no_proxy_stop=1.""" from app.services import scheduler as sched diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py index 24150a5c..2d9d7b46 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py @@ -28,6 +28,7 @@ from scraper_kit.cian_state_parser import extract_all_states, extract_state from scraper_kit.offer_price_history import validate_diff_percent from scraper_kit.providers._base import build_curl_cffi_session from scraper_kit.providers._proxy import curl_proxy_url +from scraper_kit.proxy_errors import caused_by_no_proxy from scraper_kit.repair_state_normalizer import ( infer_repair_state_from_text, normalize_repair_state, @@ -123,12 +124,22 @@ async def fetch_detail( пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают исключение в `errors_count`, а на own-session-пути оно дополнительно снимает узел с выдачи Циану через `curl_proxy_url`. + NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а + не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое, + поэтому решение «оборвать батч» принимает вызывающий (#3197). """ if browser_fetcher is not None: # Browser path: get fully JS-rendered HTML; same parse path follows. try: html = await browser_fetcher.fetch(offer_url) except Exception as exc: + # «Пул пуст» — не отказ страницы: запрос не уходил вовсе, и вызывающий обязан + # оборвать батч (#3197). Проглоченный здесь, он приезжал наверх как None, то + # есть неотличимо от «разобрать не смогли»: стоп в cian_history_backfill.py + # никогда не срабатывал, и прогон, не ходивший к площадке, финализировался + # как 'done'. Опора — тип в цепочке причин, а не текст (#3272). + if caused_by_no_proxy(exc): + raise logger.warning("Cian detail browser fetch failed %s: %s", offer_url, exc) return None elif session is not None: