fix(tradein/estimator): IMV-путь /estimate через пул прокси; пустой пул — честная деградация без IMV (#3386 п.в) #3397

Merged
bot-backend merged 2 commits from fix/3386-estimator-imv-proxy-pool into main 2026-09-06 06:04:08 +00:00
5 changed files with 307 additions and 4 deletions

View file

@ -1106,6 +1106,10 @@ async def scrape_avito_imv(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
# #3386: без provider'а curl_proxy_url считает use_pool=False (флаг AND
# provider is not None) → env-прокси SCRAPER_PROXY_URL, мёртвый узел (#2613).
# _kit_proxy_provider() отдаёт None при выключенных флагах — ship-dark цел.
proxy_provider=_kit_proxy_provider(),
)
except IMVAddressNotFoundError as e:
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning

View file

@ -51,6 +51,7 @@ from scraper_kit.providers.yandex.valuation import (
YandexValuationResult,
YandexValuationScraper,
)
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -79,7 +80,7 @@ from app.services.geocoder import (
)
from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house
from app.services.scraper_adapters import RealScraperConfig
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
from app.services.scraper_settings import get_scraper_delay
from app.tasks.asking_to_sold_ratio import area_bucket
@ -794,6 +795,11 @@ async def _get_or_fetch_imv_cached(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
# #3386: без provider'а `curl_proxy_url` считает use_pool=False (флаг AND
# provider is not None) и уходит на env-прокси SCRAPER_PROXY_URL — мёртвый
# узел (#2613). Один lease на вызов: acquire/release внутри curl_proxy_url,
# release в finally на всех выходах (исключение/таймаут — тоже).
proxy_provider=RealProxyProvider(),
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
logger.info(
@ -827,6 +833,7 @@ async def _get_or_fetch_imv_cached(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
proxy_provider=RealProxyProvider(), # #3386, см. первый вызов выше
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
logger.info(
@ -852,7 +859,14 @@ async def _get_or_fetch_imv_cached(
logger.warning("imv: transient error, skipping retry in estimator context: %s", e)
return None
except Exception as e:
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
if caused_by_no_proxy(e):
# #3386: «пул прокси пуст» — НАША инфраструктура, не сбой фетча и не
# transient-ошибка площадки; HTTP-запрос вообще не уходил. Отдельный текст,
# чтобы в логах /estimate это не читалось как «Авито отвалился».
# Возврат None — тот же graceful путь: ответ отдаётся без IMV-якоря, не 5xx.
logger.warning("imv: пул прокси пуст — estimator продолжает без IMV: %s", e)
else:
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
return None

View file

@ -0,0 +1,251 @@
"""IMV-путь эстиматора берёт прокси из пула, а пустой пул не роняет /estimate (#3386).
Корень: `estimator._get_or_fetch_imv_cached` звал kit `evaluate_via_imv(config=...)` БЕЗ
`proxy_provider`, а `providers/_proxy.py::curl_proxy_url` считает
`use_pool = флаг AND proxy_provider is not None` то есть пул был выключен по построению
и curl-сессия уходила на env-прокси `SCRAPER_PROXY_URL` (мёртвый узел, #2613).
Три проверки по значению:
(а) оба вызова (основной + retry с «очищенным» адресом) передают provider'а;
(б) пул пуст + environment=production `_get_or_fetch_imv_cached` отдаёт None
(ответ /estimate без IMV-якоря), НЕ исключение, и причина в логе честная
«пул прокси пуст», а не «fetch failed»;
(в) lease освобождён ровно один раз и на успехе, и на ошибке IMV.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.contracts import ProxyLease
from scraper_kit.providers.avito.imv import (
IMVAddressNotFoundError,
IMVEvaluation,
IMVGeo,
IMVTransientError,
)
from app.core.config import settings
from app.services import estimator
from app.services.estimator import _get_or_fetch_imv_cached
def _fake_evaluation() -> IMVEvaluation:
return IMVEvaluation(
cache_key="c" * 64,
address="ЕКБ, ул. Тургенева, 4",
rooms=2,
area_m2=50.0,
floor=3,
floor_at_home=9,
house_type="panel",
renovation_type="cosmetic",
has_balcony=False,
has_loggia=False,
geo=IMVGeo(geo_hash="JWT"),
recommended_price=6_000_000,
lower_price=5_800_000,
higher_price=6_300_000,
market_count=100,
)
def _db_cache_miss() -> MagicMock:
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
return db
async def _call(db: Any, *, address: str) -> Any:
return await _get_or_fetch_imv_cached(
db,
address=address,
rooms=2,
area_m2=50.0,
floor=3,
floor_at_home=9,
house_type="panel",
renovation_type="cosmetic",
has_balcony=False,
has_loggia=False,
)
# ── (а) проводка provider'а в оба вызова ────────────────────────────────────
def test_imv_call_passes_proxy_provider() -> None:
"""Основной вызов получает proxy_provider (на main здесь None → пул выключен)."""
mock_evaluate = AsyncMock(return_value=_fake_evaluation())
async def _run() -> None:
with (
patch.object(estimator, "evaluate_via_imv", new=mock_evaluate),
patch.object(estimator, "save_imv_evaluation", return_value=1),
):
assert await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4") is not None
anyio.run(_run)
_, kwargs = mock_evaluate.call_args
assert kwargs.get("proxy_provider") is not None
def test_imv_cleaned_address_retry_passes_proxy_provider() -> None:
"""Retry по «очищенному» адресу — второй call site, тот же контракт."""
calls: list[dict[str, Any]] = []
async def _evaluate(**kwargs: Any) -> IMVEvaluation:
calls.append(kwargs)
if len(calls) == 1:
raise IMVAddressNotFoundError("no point")
return _fake_evaluation()
async def _run() -> None:
with (
patch.object(estimator, "evaluate_via_imv", new=_evaluate),
patch.object(estimator, "save_imv_evaluation", return_value=1),
):
# Префикс из _NOISE_PREFIX_RE — иначе cleaned == address и retry не будет.
assert await _call(_db_cache_miss(), address="Склад, ул. Тургенева, 4") is not None
anyio.run(_run)
assert len(calls) == 2, "retry с очищенным адресом не состоялся — тест ничего не проверил"
assert calls[1]["address"] == "ул. Тургенева, 4"
assert calls[1].get("proxy_provider") is not None
# ── (б) пустой пул в проде: деградация, а не 5xx ────────────────────────────
class _EmptyPoolProvider:
"""acquire → None (все узлы avito забанены/заняты)."""
def acquire(self, provider: str) -> ProxyLease | None:
return None
def release(self, lease: ProxyLease) -> None: # pragma: no cover — не должно звучать
raise AssertionError("release без lease")
def mark_health(self, lease: ProxyLease, ok: bool, **kw: Any) -> None: # pragma: no cover
raise AssertionError("mark_health без lease")
def mark_banned(self, lease: ProxyLease, *, source: str) -> None: # pragma: no cover
raise AssertionError("mark_banned без lease")
def test_empty_pool_in_production_degrades_without_imv(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Пул пуст + прод → None (ответ без IMV) + честная причина в логе, без исключения."""
monkeypatch.setattr(settings, "use_proxy_pool_curl", True)
monkeypatch.setattr(settings, "environment", "production")
# Реальный evaluate_via_imv: NoProxyAvailableError поднимается в curl_proxy_url ДО
# любого HTTP-запроса, поэтому сеть здесь не нужна и не трогается.
def _no_http(*a: Any, **kw: Any) -> Any: # pragma: no cover — не должно вызваться
raise AssertionError("HTTP-запрос при пустом пуле — прокси-гейт не сработал")
result: Any = "unset"
async def _run() -> None:
nonlocal result
with (
# Без create=True: `RealProxyProvider` импортирован в estimator (estimator.py:83),
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
# оставило бы тест зелёным против фантома.
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider),
patch("curl_cffi.requests.AsyncSession", _no_http),
):
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
anyio.run(_run)
assert result is None
assert "пул прокси пуст" in caplog.text, caplog.text
assert "fetch failed" not in caplog.text, "причина подменена на неспецифичную"
# ── (в) lease освобождён ровно один раз на обоих исходах ────────────────────
class _CountingProvider:
def __init__(self) -> None:
self.acquired = 0
self.released: list[int] = []
self.health: list[bool] = []
def acquire(self, provider: str) -> ProxyLease | None:
self.acquired += 1
return ProxyLease(id=7, url="http://pool-node:3128", kind="datacenter", rotate_url=None)
def release(self, lease: ProxyLease) -> None:
self.released.append(lease.id)
def mark_health(self, lease: ProxyLease, ok: bool, **kw: Any) -> None:
self.health.append(ok)
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
pass
def _run_with_pool(
monkeypatch: pytest.MonkeyPatch, *, evaluate_side_effect: Any
) -> tuple[Any, _CountingProvider]:
monkeypatch.setattr(settings, "use_proxy_pool_curl", True)
monkeypatch.setattr(settings, "environment", "production")
provider = _CountingProvider()
session = MagicMock()
session.close = AsyncMock()
result: Any = "unset"
async def _run() -> None:
nonlocal result
with (
patch.object(estimator, "RealProxyProvider", lambda: provider),
patch.object(estimator, "save_imv_evaluation", return_value=1),
patch("curl_cffi.requests.AsyncSession", lambda *a, **kw: session),
# Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
patch(
"scraper_kit.providers.avito.imv._geocode",
new=AsyncMock(return_value=IMVGeo(geo_hash="JWT")),
),
patch(
"scraper_kit.providers.avito.imv._imv_evaluate",
new=AsyncMock(**evaluate_side_effect),
),
):
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
anyio.run(_run)
return result, provider
def test_lease_released_once_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
result, provider = _run_with_pool(
monkeypatch, evaluate_side_effect={"return_value": _fake_evaluation()}
)
assert result is not None
assert provider.acquired == 1
assert provider.released == [7]
assert provider.health == [True]
def test_lease_released_once_on_imv_error(monkeypatch: pytest.MonkeyPatch) -> None:
result, provider = _run_with_pool(
monkeypatch, evaluate_side_effect={"side_effect": IMVTransientError("502 от Авито")}
)
assert result is None # graceful: /estimate отвечает без IMV
assert provider.acquired == 1
assert provider.released == [7]
assert provider.health == [False] # ошибка засчитана узлу

View file

@ -9,11 +9,14 @@ class-based провайдера (YandexValuationScraper):
- флаг on + lease fetch через lease.url, mark_health вызван, release в finally
(в prod и dev одинаково пул выдал лизу, отказа быть не должно);
- исключение внутри блока mark_health(ok=False) + release всё равно (lease не течёт);
- отмена/прерывание (CancelledError/KeyboardInterrupt/SystemExit BaseException, не
Exception) внутри блока тот же ok=False, исключение пролетает наружу;
- acquire кинул fallback env (dev) / NoProxyAvailableError (prod).
"""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
from typing import Any
@ -159,6 +162,30 @@ def test_plain_exception_does_not_call_mark_banned() -> None:
assert spy.release_calls == [7]
@pytest.mark.parametrize(
"exc_type", [asyncio.CancelledError, KeyboardInterrupt, SystemExit], ids=lambda t: t.__name__
)
def test_base_exception_marks_fail_and_still_releases(exc_type: type[BaseException]) -> None:
"""Отмена/прерывание внутри блока — тоже ok=False, а не «узел здоров».
`asyncio.CancelledError` наследует BaseException, а не Exception: при таймауте
(`asyncio.wait_for` в `_with_budget` эстиматора) отмена приходит внутрь await'а
ВНУТРИ блока. С `except Exception` она пролетала мимо, `ok` оставался True и
в finally узел, из-за которого запрос завис до отмены, записывался ЗДОРОВЫМ.
KeyboardInterrupt/SystemExit тот же путь: health честный, но исключение
обязано пролететь наружу (`raise` в блоке сохранён, не проглатываем).
"""
cfg = _FakeConfig(use_proxy_pool_curl=True)
spy = _SpyProvider(_LEASE)
with pytest.raises(exc_type):
with curl_proxy_url(cfg, spy, "avito", env_fallback_url=None) as url:
assert url == _LEASE.url
raise exc_type()
assert spy.mark_health_calls == [(7, False)]
assert spy.release_calls == [7] # ровно один раз, lease не течёт
assert spy.mark_banned_calls == [] # отмена ≠ бан площадки
def test_acquire_raises_falls_back_to_env() -> None:
cfg = _FakeConfig(use_proxy_pool_curl=True)
spy = _SpyProvider(_LEASE, acquire_raises=True)

View file

@ -5,7 +5,8 @@
(env_fallback_url) curl-пути ходят ровно как сейчас, прод не меняется (легитимный
dev/no-op путь см. providers/cian/detail.py:118).
- Флаг on + пул выдал lease yield lease.url; на выходе mark_health(ok) + release(lease).
ok=True если блок отработал без исключения, ok=False если внутри поднялось (бан/ошибка).
ok=True если блок отработал без исключения, ok=False если внутри поднялось (бан/ошибка/
отмена ловим BaseException, иначе таймаут-cancel записывал бы зависший узел здоровым).
- Флаг on + пул пуст/ошибка acquire + окружение НЕ "production" (dev/test) fallback на
env_fallback_url, НЕ падаем (легитимно для локальной разработки/тестов).
- Флаг on + пул пуст/ошибка acquire + окружение "production" (#2616 шаг 1) → НЕ идём на
@ -112,7 +113,13 @@ def curl_proxy_url(
banned = False
try:
yield lease.url
except Exception as exc:
except BaseException as exc:
# BaseException, а не Exception: `asyncio.CancelledError` (таймаут `wait_for` —
# напр. `_with_budget` эстиматора — отменяет await ВНУТРИ блока) наследует
# BaseException. С `except Exception` отмена пролетала мимо, ok оставался True,
# и в finally узел, из-за которого запрос завис до отмены, писался ЗДОРОВЫМ.
# `raise` сохранён — KeyboardInterrupt/SystemExit пролетают наружу как раньше,
# меняется только честность health-сигнала.
ok = False
banned = isinstance(exc, ProxyBanError)
raise