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(
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,

View file

@ -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

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.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: