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(
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue