fix(tradein/imv): домовая оценка ходит в сайдкар через пул, а не через чужой env-прокси (#2698) #2708

Merged
bot-backend merged 1 commit from fix/2698-imv-outage into main 2026-08-06 06:52:16 +00:00
4 changed files with 181 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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