fix(tradein/imv): домовая оценка ходит в сайдкар через пул, а не через чужой env-прокси (#2698)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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 3m10s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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 3m10s
Домовая оценка Авито не сохранила ни одной записи с 03.07 — 34 дня, каждый
прогон 35 отказов из 35 попыток. Отказ приходил от НАШЕГО сайдкара
(tradein-browser:3000/fetch-json): 1240 домов «503 Service Unavailable»
(до 02.08), затем 83 «500 Internal Server Error» и 31 «403 auth rejected»
(05.08). При этом сам сайдкар был жив.
1. Проводка пула. backfill_house_imv конструировал
BrowserFetcher(source="avito", endpoint=...) без proxy_provider/use_pool/
environment — единственный avito-путь без пула (avito_city_sweep его
подключает, orchestration/pipeline.py:519). Без них тело POST /fetch-json
уходит БЕЗ "proxy", и сайдкар берёт свой env-прокси SCRAPER_PROXY_URL. На
проде это узел пула id=1 (asocks-residential-1, provider_affinity='domclick')
— узел, который proxy_pool.acquire('avito') не выдал бы никогда: он отбирает
affinity IN (provider,'any'), а последний узел выделенной affinity ещё и
защищён от fallback (173_scrape_proxies_add_domclick_affinity.sql).
Контрольная группа в тех же сутках: avito_city_sweep и
avito_newbuilding_sweep тянули сотни объявлений через ТОТ ЖЕ сайдкар и тот
же инстанс камуфокса (01.08: 78/69 и 484/447), пока house_imv_backfill
получал 35 отказов из 35. Разница ровно одна — поле "proxy" в теле запроса.
Второй эффект этой же дыры: запрос без "proxy" роняет сайдкару желаемый
прокси на env → relaunch камуфокса на каждый дом (server.py::_ensure_browser),
то есть путь IMV ещё и сбивал тёплый браузер sweep'ам.
environment пробрасывается ради #2616 шаг 1: иначе прод-отказ «пул пуст»
на этом пути мёртв и фетчер молча уходит на тот самый env-прокси.
2. Причина отказа больше не выбрасывается. Сайдкар кладёт её в тело
({"error": "browser unavailable (proxy may be down)"} / {"error": "Error:
Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}), а resp.raise_for_status() печатает
только код: ровно строка «Server error '503 Service Unavailable' for url
'http://tradein-browser:3000/fetch-json'» и лежала 34 дня в
houses.imv_error_reason у 1240 домов. Общий _raise_for_sidecar_status в
browser_fetcher (оба вызова, /fetch и /fetch-json) добавляет тело в текст
ошибки; тип исключения прежний (HTTPStatusError ⊂ HTTPError), retry-политика
не меняется.
Тесты: проводка пула доезжает до фетчера; причина сайдкара доезжает до
вызывающего на обоих эндпоинтах. Прежний parity-тест эндпоинта расширен
новыми аргументами (не ослаблен).
Не чинится кодом (в #2704): 403 от самой площадки и живость единственного
исходящего IP — ротация пула ждёт ASOCKS_API_TOKEN (#2638).
Refs #2698
This commit is contained in:
parent
58dcd76d2e
commit
aef43df650
4 changed files with 181 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
114
tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py
Normal file
114
tradein-mvp/backend/tests/test_2698_imv_sidecar_wiring.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue