From 80380dc32a0965c099e2b0170c7639225194f0eb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 19 Jun 2026 12:43:28 +0300 Subject: [PATCH] fix(tradein): budget Cian valuation call to stop 25s estimate stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cian Valuation enrichment was the only ungated external call in estimate_quality() — its internal curl timeout (~25s) blocks the whole /estimate request on every cache-miss, intermittently pushing latency past the gateway/patience window (perceived as 'сервис не считает'). Wrap it in _with_budget() (mirrors the existing Yandex-valuation guard): on timeout it degrades to None — the same graceful path as a network error — instead of stalling. New setting estimate_cian_valuation_timeout_s (default 8.0s, env ESTIMATE_CIAN_VALUATION_TIMEOUT_S). --- tradein-mvp/backend/app/core/config.py | 4 +- tradein-mvp/backend/app/services/estimator.py | 28 +++--- .../tests/test_estimator_cian_budget.py | 95 +++++++++++++++++++ 3 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_cian_budget.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 52347ceb..3942dd53 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 675ccec0..44e5d480 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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"}) diff --git a/tradein-mvp/backend/tests/test_estimator_cian_budget.py b/tradein-mvp/backend/tests/test_estimator_cian_budget.py new file mode 100644 index 00000000..e82ebbeb --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_cian_budget.py @@ -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)