diff --git a/tradein-mvp/backend/tests/test_imv_status_classification.py b/tradein-mvp/backend/tests/test_imv_status_classification.py new file mode 100644 index 00000000..b071c975 --- /dev/null +++ b/tradein-mvp/backend/tests/test_imv_status_classification.py @@ -0,0 +1,64 @@ +"""Классификация HTTP-статусов IMV: временное не должно становиться приговором. + +Замер на проде 01.09.2026: 93 дома заперты в терминальном imv_status='error', +из них 19 — с причиной «HTTP 429». Rate-limit — канонический ВРЕМЕННЫЙ отказ, +и механика повтора для таких существует (transient_error + retry-lane), но 429 +проваливался в общий 4xx-fall-through `_raise_for_status_categorized` и +приезжал в house_imv_backfill голым RuntimeError. Тот типизирует только +IMV*-исключения → дом получал 'error' и выпадал из повторных пакетов навсегда. + +Тест держит ГРАНИЦУ классификации с обеих сторон: 429/408 — transient, +400 — по-прежнему НЕ transient (тело безликое «Bad Request», оснований считать +его временным нет; сделать 400 ретраебельным значило бы вечно долбить дома с +реально кривыми параметрами). +""" + +from __future__ import annotations + +import pytest +from scraper_kit.providers.avito.imv import ( + IMVAuthError, + IMVTransientError, + _raise_for_status_categorized, +) + + +class _Resp: + def __init__(self, status: int, body: str = "") -> None: + self.status_code = status + self.text = body + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +@pytest.mark.parametrize("status", [429, 408]) +def test_rate_limit_and_timeout_are_transient(status: int) -> None: + with pytest.raises(IMVTransientError): + _raise_for_status_categorized(_Resp(status), "imv-evaluate") + + +@pytest.mark.parametrize("status", [500, 502, 503]) +def test_5xx_stays_transient(status: int) -> None: + with pytest.raises(IMVTransientError): + _raise_for_status_categorized(_Resp(status), "imv-evaluate") + + +@pytest.mark.parametrize("status", [401, 403]) +def test_auth_stays_auth(status: int) -> None: + with pytest.raises(IMVAuthError): + _raise_for_status_categorized(_Resp(status), "imv-evaluate") + + +def test_400_is_not_transient() -> None: + # Обратная сторона границы: безликий Bad Request не становится + # ретраебельным — иначе дома с кривыми параметрами долбились бы вечно. + with pytest.raises(RuntimeError) as ei: + _raise_for_status_categorized(_Resp(400, '{"code":400}'), "imv-evaluate") + assert not isinstance(ei.value, IMVTransientError) + assert "400" in str(ei.value) + + +def test_2xx_passes_silently() -> None: + _raise_for_status_categorized(_Resp(200), "imv-evaluate") diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py index e36818f8..de0a000c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py @@ -594,6 +594,15 @@ def _raise_for_status_categorized(resp: Any, context: str) -> None: status = resp.status_code if status in (401, 403): raise IMVAuthError(f"Avito {context}: HTTP {status} — auth rejected / quota exceeded") + if status in (408, 429): + # Rate-limit и таймаут — канонические ВРЕМЕННЫЕ отказы, но до 01.09.2026 + # они проваливались в общий 4xx-fall-through ниже и приезжали в + # house_imv_backfill голым RuntimeError. Тот classифицирует только + # типизированные IMV*-исключения, поэтому дом получал ТЕРМИНАЛЬНЫЙ + # imv_status='error' и выпадал из повторных пакетов навсегда. + # Замер на проде: 19 домов заперты в error с причиной «HTTP 429» — + # ретраебельный отказ стал вечным приговором. + raise IMVTransientError(f"Avito {context}: HTTP {status} — rate limited / timeout") if status >= 500: raise IMVTransientError(f"Avito {context}: HTTP {status} — transient server error") if status >= 400: