fix(tradein): budget Cian valuation call to stop 25s estimate stalls
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped

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).
This commit is contained in:
bot-backend 2026-06-19 12:43:28 +03:00
parent ee2703419b
commit 80380dc32a
3 changed files with 113 additions and 14 deletions

View file

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

View file

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

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