Merge pull request 'imv: HTTP 429 больше не приговаривает дом навечно' (#3314) from fix/imv-429-transient into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 2m7s

This commit is contained in:
bot-backend 2026-09-01 16:27:54 +00:00
commit 4fe0538ea0
2 changed files with 73 additions and 0 deletions

View file

@ -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")

View file

@ -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: