fix(#3197): пустой пул пробрасывается из cian fetch_detail; тест через настоящий fetch_detail
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m58s

This commit is contained in:
bot-backend 2026-09-06 03:05:32 +05:00
parent 8129e21b95
commit 0c21e522c0
3 changed files with 109 additions and 20 deletions

View file

@ -169,7 +169,10 @@ async def _execute_cian_backfill(
runs_mod.mark_failed( runs_mod.mark_failed(
db, run_id, "пул прокси пуст — к площадке не ходили (#3197)", counters 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 СТОП (пул пуст) — " "scheduler: cian_history_backfill run_id=%d СТОП (пул пуст) — "
"listings=%d/%d houses=%d/%d %.1fs", "listings=%d/%d houses=%d/%d %.1fs",
run_id, run_id,

View file

@ -9,9 +9,12 @@ env/direct» (#2616) на этом пути был мёртв, потому чт
до конструктора не доезжал. Соседи уже починены: domclick (#3197 ч.1, см. до конструктора не доезжал. Соседи уже починены: domclick (#3197 ч.1, см.
test_3197_domclick_proxy_pool_wiring.py) и house_imv/avito (#2698). 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 from __future__ import annotations
import os import os
from contextlib import ExitStack
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, ClassVar from typing import Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import pytest import pytest
from scraper_kit.providers.cian.detail import fetch_detail
from scraper_kit.proxy_errors import NoProxyAvailableError from scraper_kit.proxy_errors import NoProxyAvailableError
from app.tasks import cian_history_backfill as chb from app.tasks import cian_history_backfill as chb
@ -52,6 +58,40 @@ class _CapturingFetcher:
return None 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: def _mock_db(n_rows: int) -> MagicMock:
rows = [ rows = [
{"id": i + 1, "source_url": f"https://ekb.cian.ru/sale/flat/{i + 1}/"} {"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 return db
async def _run_listings(db: MagicMock, fetch: Any) -> chb.CianBackfillResult: async def _run_listings(
with ( db: MagicMock, fetch: Any = None, fetcher: type = _CapturingFetcher
patch.object(chb, "BrowserFetcher", _CapturingFetcher), ) -> chb.CianBackfillResult:
patch(_FETCH, fetch), """Прогон listings-блока. `fetch=None` ⇒ `fetch_detail` НЕ подменяется (рабочий тракт)."""
patches = [
patch.object(chb, "BrowserFetcher", fetcher),
patch(_SAVE, return_value=True), patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock), patch(_SLEEP, new_callable=AsyncMock),
patch(_DELAY, return_value=0.0), 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( return await chb.backfill_cian_history(
db, batch_size=10, do_listings=True, do_houses=False, do_valuations=False 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: async def test_empty_pool_stops_the_batch_on_first_listing() -> None:
"""«Пул пуст» на первом объявлении обрывает батч, а не крутит весь список.""" """«Пул пуст» на первом объявлении обрывает батч, а не крутит весь список.
_CapturingFetcher.captured = {}
def _raise_wrapped(*_a: object, **_kw: object) -> None: Замокано: БД (`_mock_db`), `save_detail_enrichment`, `asyncio.sleep`, задержка и
# Ровно как в проде: провайдер заворачивает сбой фетча в своё исключение, и сам `BrowserFetcher` подделка кончается на `fetch()`, который поднимает
# «пул пуст» приезжает наверх под видом отказа площадки. `NoProxyAvailableError` вместо HTTP-запроса в сайдкар.
try:
raise NoProxyAvailableError("cian")
except NoProxyAvailableError as exc:
raise RuntimeError("cian detail fetch failed") from exc
fetch = AsyncMock(side_effect=_raise_wrapped) НЕ замокан `fetch_detail` прогон идёт через настоящую браузерную ветку
result = await _run_listings(_mock_db(3), fetch) `providers/cian/detail.py`. Прежняя редакция подменяла `fetch_detail` моком,
который сам поднимал исключение, и была зелёной по построению: живая функция
исключение ГЛОТАЛА и возвращала None, поэтому стоп не срабатывал ни разу
(ревью PR #3382 — прогон, не ходивший к площадке, уходил в 'done').
"""
_EmptyPoolFetcher.calls = []
result = await _run_listings(_mock_db(3), fetcher=_EmptyPoolFetcher)
# Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате # Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате
# должно означать «прошли 3 строки вместо 1», а не «поля нет». # должно означать «прошли 3 строки вместо 1», а не «поля нет».
assert fetch.await_count == 1, "к площадке ходили только один раз — пул пуст с первого" assert _EmptyPoolFetcher.calls == ["https://ekb.cian.ru/sale/flat/1/"], (
"к сайдкару ходили ровно один раз — пул пуст с первого объявления"
)
assert result.listings_processed == 1, "батч обязан оборваться, а не пройти все 3 строки" assert result.listings_processed == 1, "батч обязан оборваться, а не пройти все 3 строки"
assert getattr(result, "no_proxy_stop", False) is True 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 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: async def test_run_marked_failed_with_no_proxy_stop_counter() -> None:
"""Прогон с пустым пулом финализируется как failed + counters.no_proxy_stop=1.""" """Прогон с пустым пулом финализируется как failed + counters.no_proxy_stop=1."""
from app.services import scheduler as sched from app.services import scheduler as sched

View file

@ -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.offer_price_history import validate_diff_percent
from scraper_kit.providers._base import build_curl_cffi_session from scraper_kit.providers._base import build_curl_cffi_session
from scraper_kit.providers._proxy import curl_proxy_url 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 ( from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text, infer_repair_state_from_text,
normalize_repair_state, normalize_repair_state,
@ -123,12 +124,22 @@ async def fetch_detail(
пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают
исключение в `errors_count`, а на own-session-пути оно дополнительно снимает исключение в `errors_count`, а на own-session-пути оно дополнительно снимает
узел с выдачи Циану через `curl_proxy_url`. узел с выдачи Циану через `curl_proxy_url`.
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое,
поэтому решение «оборвать батч» принимает вызывающий (#3197).
""" """
if browser_fetcher is not None: if browser_fetcher is not None:
# Browser path: get fully JS-rendered HTML; same parse path follows. # Browser path: get fully JS-rendered HTML; same parse path follows.
try: try:
html = await browser_fetcher.fetch(offer_url) html = await browser_fetcher.fetch(offer_url)
except Exception as exc: 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) logger.warning("Cian detail browser fetch failed %s: %s", offer_url, exc)
return None return None
elif session is not None: elif session is not None: