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
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:
parent
8129e21b95
commit
0c21e522c0
3 changed files with 109 additions and 20 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue