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).
95 lines
3.6 KiB
Python
95 lines
3.6 KiB
Python
"""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)
|