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
Алерты 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 (починка разбора не чинит строки сама).
64 lines
2.9 KiB
Python
64 lines
2.9 KiB
Python
"""Классификация 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")
|