diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index 935f3918..edb02137 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -577,8 +577,33 @@ async def backfill_house_imv( # + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853). # Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше # (поведение байт-в-байт идентично доспринтовому). + # + # #2698: proxy_provider/use_pool/environment — обязательная часть проводки, а не + # опция. Без них BrowserFetcher не кладёт "proxy" в тело POST /fetch-json, и сайдкар + # берёт свой env-прокси SCRAPER_PROXY_URL — на проде это узел пула id=1 + # (asocks-residential-1, provider_affinity='domclick'), который proxy_pool.acquire + # («affinity IN (provider,'any')» + защита последнего узла выделенной affinity от + # fallback) для avito не выдал бы НИКОГДА. Результат: 03.07-05.08 все 35 из 35 попыток + # каждого прогона падали на геокодере A (1240 домов — 503 «browser unavailable», затем + # 500 «Page.goto: NS_ERROR_PROXY_BAD_GATEWAY» и 403 от самого Авито), пока + # avito_city_sweep/avito_newbuilding_sweep в те же дни тянули сотни объявлений через + # ТОТ ЖЕ сайдкар и тот же инстанс камуфокса — они пул подключают (pipeline.py). Хуже: + # запрос без "proxy" в теле ещё и роняет сайдкару желаемый прокси на env → relaunch + # камуфокса на каждый дом (server.py::_ensure_browser). if settings.avito_imv_use_browser_fetcher: - async with BrowserFetcher(source="avito", endpoint=settings.browser_http_endpoint) as _bf: + # lazy import — тот же цикл scraper_adapters↔этот модуль, что и у RealScraperConfig. + from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig + + _cfg = RealScraperConfig() + async with BrowserFetcher( + source="avito", + endpoint=settings.browser_http_endpoint, + proxy_provider=RealProxyProvider(), + use_pool=_cfg.use_proxy_pool_browser, + # #2616 шаг 1: без environment прод-отказ «пул пуст» мёртв на этом пути — + # фетчер молча ушёл бы на тот самый env-прокси (см. _acquire_lease). + environment=_cfg.environment, + ) as _bf: await _run_loop(_bf) else: await _run_loop(None) diff --git a/tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py b/tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py new file mode 100644 index 00000000..6c3d64cb --- /dev/null +++ b/tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py @@ -0,0 +1,114 @@ +"""#2698 — домовая оценка Авито ходила в сайдкар без прокси пула, и причина отказа терялась. + +Два независимых дефекта одного пути (backfill_house_imv → BrowserFetcher → POST +/fetch-json → tradein-browser): + +1. `BrowserFetcher(source="avito", endpoint=...)` конструировался БЕЗ + proxy_provider/use_pool/environment — единственный avito-путь без проводки пула + (avito_city_sweep её подключает, orchestration/pipeline.py). Тело POST уходило без + "proxy", сайдкар брал env-прокси SCRAPER_PROXY_URL = узел пула id=1 + (provider_affinity='domclick'), который `proxy_pool.acquire('avito')` не выдал бы + никогда. Прод 03.07-05.08: 35 отказов из 35 попыток в каждом прогоне при живом + сайдкаре и работающих в те же дни sweep'ах. + +2. Причина отказа приходила в теле ответа сайдкара ({"error": "browser unavailable + (proxy may be down)"} / "Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"), а + `resp.raise_for_status()` её выбрасывал — в houses.imv_error_reason 34 дня лежал + голый код статуса. + +Сеть/БД/камуфокс замоканы. +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.browser_fetcher import BrowserFetcher + +from app.services import house_imv_backfill as hib + +# ── (1) проводка пула на пути домовой оценки ───────────────────────────────── + + +class _CapturingFetcher: + captured: ClassVar[dict[str, Any]] = {} + + def __init__(self, **kwargs: Any) -> None: + _CapturingFetcher.captured = kwargs + + async def __aenter__(self) -> _CapturingFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + +async def test_backfill_browser_fetcher_gets_proxy_pool_wiring( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Прод-случай #2698: без этих трёх аргументов сайдкар уходил на env-прокси.""" + _CapturingFetcher.captured = {} + monkeypatch.setattr(hib.settings, "avito_imv_use_browser_fetcher", True) + monkeypatch.setattr(hib.settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(hib.settings, "environment", "production") + monkeypatch.setattr(hib, "BrowserFetcher", _CapturingFetcher) + monkeypatch.setattr(hib, "_process_one_house", AsyncMock(return_value="ok")) + + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [ + {"id": 1, "address": "ЕКБ, ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6} + ] + await hib.backfill_house_imv(db, batch_size=1) + + captured = _CapturingFetcher.captured + assert captured["source"] == "avito" + assert captured["proxy_provider"] is not None, "без провайдера пул не подключится" + assert captured["use_pool"] is True, "флаг пула должен доезжать до фетчера" + # #2616 шаг 1: иначе прод-отказ «пул пуст» мёртв и мы молча уходим на env-прокси. + assert captured["environment"] == "production" + + +# ── (2) причина отказа сайдкара доезжает до вызывающего ────────────────────── + + +def _client_returning(status: int, body: dict[str, Any], url: str) -> MagicMock: + resp = httpx.Response(status, json=body, request=httpx.Request("POST", url)) + client = MagicMock() + client.post = AsyncMock(return_value=resp) + client.aclose = AsyncMock(return_value=None) + return client + + +async def test_fetch_json_error_carries_sidecar_reason() -> None: + """503 сайдкара: в тексте ошибки должна быть ПРИЧИНА, а не только код статуса.""" + endpoint = "http://tradein-browser:3000" + async with BrowserFetcher(source="avito", endpoint=endpoint) as bf: + bf._client = _client_returning( # type: ignore[assignment] + 503, {"error": "browser unavailable (proxy may be down)"}, f"{endpoint}/fetch-json" + ) + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await bf.fetch_json("https://www.avito.ru/web/1/coords/by_address?address=X") + + message = str(exc_info.value) + assert "browser unavailable (proxy may be down)" in message + assert "503" in message + + +async def test_fetch_error_carries_sidecar_reason() -> None: + """Тот же инвариант для /fetch — общий helper, а не заплатка на одном вызове.""" + endpoint = "http://tradein-browser:3000" + async with BrowserFetcher(source="avito", endpoint=endpoint) as bf: + bf._client = _client_returning( # type: ignore[assignment] + 500, {"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}, f"{endpoint}/fetch" + ) + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await bf.fetch("https://www.avito.ru/evaluation/realty") + + assert "NS_ERROR_PROXY_BAD_GATEWAY" in str(exc_info.value) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py index bb924d09..7084968b 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py @@ -188,7 +188,13 @@ async def test_house_imv_backfill_browser_fetcher_uses_settings_endpoint() -> No ] await house_imv_backfill.backfill_house_imv(db, batch_size=1) - assert captured == {"source": "avito", "endpoint": settings.browser_http_endpoint} + # #2698: к endpoint= добавилась проводка пула — без неё сайдкар брал env-прокси + # (узел с affinity='domclick'), и домовая оценка падала 34 дня подряд. + assert captured["source"] == "avito" + assert captured["endpoint"] == settings.browser_http_endpoint + assert captured["proxy_provider"] is not None + assert captured["use_pool"] == settings.use_proxy_pool_browser + assert captured["environment"] == settings.environment async def test_cian_history_backfill_browser_fetcher_uses_settings_endpoint() -> None: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 74782067..c58ed589 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -46,6 +46,38 @@ _HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → ще _LEASE_ROTATE_AFTER_FAILS: int = 3 +def _raise_for_sidecar_status(resp: httpx.Response) -> None: + """`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки. + + tradein-browser кладёт причину отказа в тело: 503 ``{"error": "no proxy configured — + refusing direct connection (prod)"}`` / ``{"error": "browser unavailable (proxy may be + down)"}``, 500 ``{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY ..."}`` + (browser/server.py, fetch_handler + fetch_json_handler). До #2698 тело выбрасывалось: + httpx.HTTPStatusError печатает только «Server error '503 Service Unavailable' for url + 'http://tradein-browser:3000/fetch-json'» — и ровно эта строка 34 дня лежала в + houses.imv_error_reason у 1240 домов. Отказ был виден, причина — нет. + + Тип исключения не меняется (HTTPStatusError ⊂ HTTPError), поэтому retry-политика + fetch()/fetch_json() и обработка у вызывающих остаются прежними. + """ + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + try: + detail = " ".join((resp.text or "").split())[:300] + except Exception: + # Тело не прочиталось/не декодируется — причина не обязана быть; отдаём + # исходную ошибку, а не роняем вызывающего на разборе тела. + raise exc from None + if not detail: + raise + raise httpx.HTTPStatusError( + f"{exc} | tradein-browser: {detail}", + request=exc.request, + response=exc.response, + ) from exc + + class BrowserFetcher: """Async context manager: HTTP-клиент к tradein-browser HTTP-сервису. @@ -461,7 +493,7 @@ class BrowserFetcher: payload["proxy_kind"] = proxy_kind try: resp = await self._client.post(f"{self._endpoint}/fetch", json=payload) - resp.raise_for_status() + _raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код data: dict[str, str] = resp.json() html = data["html"] except Exception: @@ -501,7 +533,7 @@ class BrowserFetcher: payload["proxy_kind"] = proxy_kind try: resp = await self._client.post(f"{self._endpoint}/fetch-json", json=payload) - resp.raise_for_status() + _raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код data: dict = resp.json() except Exception: self._report_fetch_result(False)