fix(tradein): budget Cian valuation call to stop 25s estimate stalls («не считает») #1777
3 changed files with 113 additions and 14 deletions
|
|
@ -138,8 +138,10 @@ class Settings(BaseSettings):
|
|||
# graceful-путь что и сетевая ошибка), а НЕ роняет весь /estimate в 5xx.
|
||||
# Держать суммарный budget ниже Caddy read/write timeout (см.
|
||||
# deploy/Caddyfile.tradein-fragment). ENV: ESTIMATE_YANDEX_VALUATION_TIMEOUT_S,
|
||||
# ESTIMATE_GEOCODE_BUDGET_S, ESTIMATE_HOUSE_META_TIMEOUT_S.
|
||||
# ESTIMATE_CIAN_VALUATION_TIMEOUT_S, ESTIMATE_GEOCODE_BUDGET_S,
|
||||
# ESTIMATE_HOUSE_META_TIMEOUT_S.
|
||||
estimate_yandex_valuation_timeout_s: float = 8.0
|
||||
estimate_cian_valuation_timeout_s: float = 8.0
|
||||
estimate_geocode_budget_s: float = 12.0
|
||||
estimate_house_meta_timeout_s: float = 8.0
|
||||
|
||||
|
|
|
|||
|
|
@ -1537,9 +1537,7 @@ def _fetch_anchor_comps(
|
|||
deduped_rows = list(deduped.values())
|
||||
comps = [_anchor_comp_from_row(r) for r in deduped_rows if r["price_per_m2"]]
|
||||
if len(comps) >= min_comps:
|
||||
primary_n = sum(
|
||||
1 for r in deduped_rows if r.get("listing_segment") == "novostroyki"
|
||||
)
|
||||
primary_n = sum(1 for r in deduped_rows if r.get("listing_segment") == "novostroyki")
|
||||
logger.info(
|
||||
"anchor tier=A street=%r base=%s letter=%s → %d comps (primary=%d)",
|
||||
street,
|
||||
|
|
@ -2176,16 +2174,20 @@ async def estimate_quality(
|
|||
and payload.total_floors is not None
|
||||
):
|
||||
try:
|
||||
cian_val = await estimate_via_cian_valuation(
|
||||
db,
|
||||
address=geo.full_address,
|
||||
total_area=payload.area_m2,
|
||||
rooms_count=payload.rooms,
|
||||
floor=payload.floor,
|
||||
total_floors=payload.total_floors,
|
||||
repair_type="cosmetic",
|
||||
deal_type="sale",
|
||||
use_cache=True,
|
||||
cian_val = await _with_budget(
|
||||
estimate_via_cian_valuation(
|
||||
db,
|
||||
address=geo.full_address,
|
||||
total_area=payload.area_m2,
|
||||
rooms_count=payload.rooms,
|
||||
floor=payload.floor,
|
||||
total_floors=payload.total_floors,
|
||||
repair_type="cosmetic",
|
||||
deal_type="sale",
|
||||
use_cache=True,
|
||||
),
|
||||
settings.estimate_cian_valuation_timeout_s,
|
||||
label="cian_valuation",
|
||||
)
|
||||
if cian_val is not None and cian_val.sale_price_rub:
|
||||
sources_used_pre = sorted(set(sources_used_pre) | {"cian_valuation"})
|
||||
|
|
|
|||
95
tradein-mvp/backend/tests/test_estimator_cian_budget.py
Normal file
95
tradein-mvp/backend/tests/test_estimator_cian_budget.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Cian valuation time-budget — slow Cian must degrade gracefully, not block /estimate.
|
||||
|
||||
Prod latency incident: the Cian Valuation call was the ONLY ungated enrichment in
|
||||
the estimate pipeline — it times out at ~25s on every cache-miss, blocking the whole
|
||||
request past the gateway/patience window. The Yandex valuation right above it was
|
||||
already wrapped in `_with_budget`; this mirrors that guard for Cian.
|
||||
|
||||
Contracts locked here:
|
||||
1. `estimate_cian_valuation_timeout_s` exists and defaults to 8.0s.
|
||||
2. A Cian-valuation timeout (TimeoutError surfaced by the _with_budget wrapper)
|
||||
degrades to an AggregatedEstimate WITHOUT 'cian_valuation' in sources_used and
|
||||
no 5xx — the same graceful None path as a network error.
|
||||
|
||||
Style mirrors test_estimator_null_floor_timeout.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
|
||||
def _make_fake_geo():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload_full():
|
||||
"""Floor present so the floor-gated Cian source is actually awaited."""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Учителей, 18",
|
||||
area_m2=38.8,
|
||||
rooms=1,
|
||||
floor=4,
|
||||
total_floors=16,
|
||||
)
|
||||
|
||||
|
||||
def test_cian_valuation_timeout_setting_default() -> None:
|
||||
"""The new budget setting exists and defaults to 8.0s."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert hasattr(settings, "estimate_cian_valuation_timeout_s")
|
||||
assert settings.estimate_cian_valuation_timeout_s == 8.0
|
||||
|
||||
|
||||
def test_estimate_cian_timeout_degrades_no_5xx() -> None:
|
||||
"""Cian valuation raising TimeoutError → estimate without it, no exception.
|
||||
|
||||
The _with_budget() guard wraps the Cian call; a TimeoutError must map to the
|
||||
same graceful None path as a network error. Estimator returns an
|
||||
AggregatedEstimate; 'cian_valuation' absent from sources_used.
|
||||
"""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _make_payload_full()
|
||||
|
||||
# Cian call hangs/raises a timeout — simulate the slow-upstream case.
|
||||
cian_mock = AsyncMock(side_effect=TimeoutError("cian slow"))
|
||||
|
||||
async def _run() -> None:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
result = await estimate_quality(payload, db)
|
||||
|
||||
assert result.estimate_id is not None # no 5xx — degraded gracefully
|
||||
assert "cian_valuation" not in result.sources_used
|
||||
|
||||
anyio.run(_run)
|
||||
Loading…
Add table
Reference in a new issue