gendesign/tradein-mvp/backend/tests/test_imv_status_classification.py
bot-backend dab2b43efc
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
imv: HTTP 429 больше не приговаривает дом навечно
Алерты 01.09 (house_imv: RuntimeError('HTTP 429') / ('HTTP 400')) вскрыли
дыру в классификаторе: _raise_for_status_categorized разбирает 401/403 и 5xx,
а ВЕСЬ остальной 4xx проваливается в resp.raise_for_status() и приезжает в
house_imv_backfill голым RuntimeError. Бэкфилл типизирует только
IMV*-исключения — дом получает ТЕРМИНАЛЬНЫЙ imv_status='error' и выпадает из
повторных пакетов навсегда.

429 — канонический ВРЕМЕННЫЙ отказ (rate limit), и механика повтора для таких
существует (transient_error + retry-lane + лимит попыток #2674). Замер на
проде: 19 домов заперты в error с причиной «HTTP 429» — ретраебельный отказ
стал вечным приговором.

429 и 408 теперь IMVTransientError. 400 НАМЕРЕННО оставлен терминальным: тело
безликое {"code":400,"message":"Bad Request"}, оснований считать его
временным нет, а ретраебельный 400 значил бы вечно долбить дома с реально
кривыми параметрами. Тест держит границу С ОБЕИХ СТОРОН — и «429 transient»,
и «400 НЕ transient».

Фальсификация: снятие ветки 408/429 даёт 2 failed по значению
(IMVTransientError не поднят), не ImportError. 9 passed, ruff чисто.

Ремонт уже запертых строк — отдельным шагом после мержа: UPDATE 19 домов
error→transient_error (починка разбора не чинит строки сама).
2026-09-01 21:21:53 +05:00

64 lines
2.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Классификация 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")