All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m12s
Блокер номер один перед открытием эндпоинта оценки наружу: анонимный запрос означал БЕЗЛИМИТ. В сервисе квот отсутствие имени пользователя трактовалось как unlimited во всех функциях, с комментарием «dev без Caddy, fail-open». Единственной защитой был общий лимит 300 запросов в минуту на IP — это анти-флуд для дешёвых запросов, а не бизнес-лимит для пайплайна на десятки секунд. 1. Fail-open больше не по умолчанию. Вызывающая сторона передаёт пустую личность только при явно включённом флаге разработки (по умолчанию выкл). 2. Анонимная личность — подписанная кука с HMAC-SHA256, отдельным каналом от X-Authenticated-User. Тот заголовок ставит Caddy и валидирует внутренним секретом; смешивать схемы нельзя, это сломало бы модель безопасности. Ключ подписи из окружения; если не задан — эфемерный на процесс, с предупреждением в лог. 3. Анонимная квота на паре «сессия + IP», переиспользует существующую таблицу и тот же атомарный инкремент под WHERE used < lim (защита от гонки #747). Честно закомментировано: смена IP или чистка куки обходит лимит — задача поднять стоимость злоупотребления, а не сделать его невозможным. 4. Отдельный жёсткий лимит частоты на оценку, проверяется ДО квоты. Переиспользован готовый SlidingWindowLimiter. Redis намеренно не задействован: прод работает одним воркером, состояние теряется только при рестарте, а основная защита — месячная квота в Postgres. Компромисс задокументирован. 5. Потолок времени ответа. Вызов Avito IMV шёл БЕЗ бюджета, в отличие от всех соседних — единственный источник неограниченного времени. Обёрнут. Суммарный худший случай: было ~186 с (36 с ограниченных плюс IMV без границы ~150 с), стало 56 с. Тесты: 2754 passed. Два существующих теста обновлены под изменившееся поведение fail-open — это ожидаемое изменение, не регрессия.
132 lines
5.1 KiB
Python
132 lines
5.1 KiB
Python
"""Avito IMV time-budget — slow/hanging IMV must degrade gracefully, not block
|
|
/estimate (#b2c-antiabuse-2).
|
|
|
|
Prod risk: `_get_or_fetch_imv_cached` was the ONLY external call in the estimate
|
|
pipeline WITHOUT a `_with_budget` guard. `evaluate_via_imv` chains up to 3
|
|
sequential HTTP requests (warm-up + geocode + evaluate), each with its own 25s
|
|
timeout (`_HTTP_TIMEOUT_SEC` in scraper_kit.providers.avito.imv), plus a possible
|
|
ONE internal retry with a "cleaned" address on `IMVAddressNotFoundError` — the
|
|
unbounded worst-case reached ~150s. Every other slow enrichment (geocode/
|
|
house_metadata/yandex_valuation/cian_valuation) was already wrapped in
|
|
`_with_budget`; this mirrors that guard for IMV.
|
|
|
|
Contracts locked here:
|
|
1. `estimate_avito_imv_timeout_s` exists with the documented default (20.0s).
|
|
2. An IMV timeout (TimeoutError surfaced by the _with_budget wrapper) degrades
|
|
to an AggregatedEstimate WITHOUT 'avito_imv' in sources_used and no 5xx —
|
|
the same graceful None path as a network error.
|
|
3. `_with_budget` actually enforces the timeout (elapsed time bound), proving
|
|
the guard is live and not merely present in code.
|
|
|
|
Style mirrors test_estimator_cian_budget.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")
|
|
|
|
import asyncio
|
|
import time
|
|
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():
|
|
"""house_type/repair_state set so the IMV-gated guard is actually satisfied
|
|
(imv_house_type/imv_renovation both non-None — see _IMV_HOUSE_TYPE_MAP /
|
|
_IMV_REPAIR_MAP in estimator.py)."""
|
|
from app.schemas.trade_in import TradeInEstimateInput
|
|
|
|
return TradeInEstimateInput(
|
|
address="ЕКБ, ул. Учителей, 18",
|
|
area_m2=38.8,
|
|
rooms=1,
|
|
floor=4,
|
|
total_floors=16,
|
|
house_type="panel",
|
|
repair_state="standard",
|
|
)
|
|
|
|
|
|
def test_avito_imv_timeout_setting_default() -> None:
|
|
"""The new budget setting exists and defaults to 20.0s."""
|
|
from app.core.config import settings
|
|
|
|
assert hasattr(settings, "estimate_avito_imv_timeout_s")
|
|
assert settings.estimate_avito_imv_timeout_s == 20.0
|
|
|
|
|
|
def test_estimate_avito_imv_timeout_degrades_no_5xx() -> None:
|
|
"""IMV raising TimeoutError → estimate without it, no exception.
|
|
|
|
The _with_budget() guard wraps the IMV call; a TimeoutError must map to the
|
|
same graceful None path as a network error. Estimator returns an
|
|
AggregatedEstimate; 'avito_imv' absent from sources_used.
|
|
"""
|
|
from app.services.estimator import estimate_quality
|
|
|
|
db = MagicMock()
|
|
payload = _make_payload_full()
|
|
|
|
imv_mock = AsyncMock(side_effect=TimeoutError("imv 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=imv_mock),
|
|
patch(
|
|
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch(
|
|
"app.services.estimator.estimate_via_cian_valuation",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
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 "avito_imv" not in result.sources_used
|
|
|
|
anyio.run(_run)
|
|
|
|
|
|
def test_estimate_avito_imv_timeout_within_budget() -> None:
|
|
"""`_with_budget` actually enforces the configured timeout — a hanging IMV
|
|
coroutine is cancelled at the budget, not left to run the unbounded
|
|
(~150s worst-case) evaluate_via_imv chain. Uses a short synthetic budget
|
|
(not the real 20.0s default) so this test stays fast."""
|
|
from app.services.estimator import _with_budget
|
|
|
|
short_budget_s = 0.2
|
|
|
|
async def _hangs_forever() -> None:
|
|
await asyncio.sleep(999)
|
|
|
|
async def _run() -> None:
|
|
start = time.monotonic()
|
|
result = await _with_budget(_hangs_forever(), short_budget_s, label="avito_imv")
|
|
elapsed = time.monotonic() - start
|
|
assert result is None
|
|
# Generous slack for CI scheduling jitter — proves the guard actually
|
|
# cancels near the budget, not merely that it exists in code.
|
|
assert elapsed < short_budget_s + 2.0
|
|
|
|
anyio.run(_run)
|