gendesign/tradein-mvp/backend/tests/scrapers/test_avito_detail_429_reconnect.py
bot-backend d65a88f704
All checks were successful
CI / changes (pull_request) Successful in 6s
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
fix(avito): reconnect-retry on detail 429 under backconnect proxy
Detail backfill (run 217) hit 5 consecutive HTTP 429s and aborted at 19 enriched
because the detail fetcher only had a same-session 429 short-retry (no reconnect).
429s are persistent: keep-alive reuses one backconnect exit IP across sequential
detail fetches -> avito rate-limits it. Mirror the merged SERP 429 fix (#1769):
widen short-retry 4->8 and add up to 3 ephemeral-session reconnects (fresh exit IP)
on 429 exhaustion before raising. Caller session never touched (as with 403 path).
2026-06-18 23:29:47 +03:00

153 lines
6.5 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.

"""curl_cffi 429 reconnect-retry на detail-странице под backconnect-прокси.
fetch_detail имел только short-retry той же сессии на 429 (_AVITO_DETAIL_429_MAX_RETRIES),
затем raise AvitoRateLimitedError. Но keep-alive detail-fetch переиспользует ОДИН
backconnect exit-IP на последовательные запросы → avito лочит этот IP, и short-retry той
же сессии не помогает (run 217: 5 подряд 429, abort на 19 enriched). Зеркало SERP-фикса
(#1769): при исчерпании short-retry под backconnect делаем ещё
_AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP),
переиспользуя ту же retry_session-машинерию что и 403-путь.
Ownership-правило: caller-provided cffi_session НИКОГДА не закрывается/не пересоздаётся —
reconnect идёт через свою эфемерную retry_session (закрываем в finally).
Включаем backconnect через scraper_proxy_url_env (scraper_proxy_url — read-only property).
Зеркало fixture-стиля test_avito_detail_403_reconnect.py.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.core.config import settings
from app.services.scrapers import avito_detail as detail_mod
from app.services.scrapers.avito_detail import fetch_detail
from app.services.scrapers.avito_exceptions import AvitoRateLimitedError
_SENTINEL = object()
def _resp(status_code: int, text: str = "") -> MagicMock:
r = MagicMock()
r.status_code = status_code
r.text = text
return r
def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619")
def _disable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "scraper_proxy_url_env", None)
@pytest.fixture(autouse=True)
def _patch_common() -> object:
"""asyncio.sleep → no-op; parse_detail_html → sentinel (нет нужды в реальном HTML)."""
with (
patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()),
patch(
"app.services.scrapers.avito_detail.parse_detail_html", return_value=_SENTINEL
) as parse,
):
yield parse
@pytest.mark.asyncio
async def test_429_short_retry_exhausted_then_reconnect_200(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""429×(short-retry budget) → 429 → reconnect (свежая сессия) → 200.
После _AVITO_DETAIL_429_MAX_RETRIES short-retry той же сессии ещё один 429 исчерпывает
short-budget → reconnect через _build_detail_session → 200. Caller-session НЕ закрыта.
"""
_enable_backconnect(monkeypatch)
# short-budget short-retries (каждый получает 429 и continue) + ещё один 429, который
# исчерпывает budget и триггерит reconnect. Итого budget+1 ответов 429 на caller-сессии.
n429 = detail_mod._AVITO_DETAIL_429_MAX_RETRIES + 1
caller = AsyncMock()
caller.get = AsyncMock(side_effect=[_resp(429)] * n429)
retry1 = AsyncMock()
retry1.get = AsyncMock(return_value=_resp(200, "<html/>"))
build = MagicMock(side_effect=[retry1])
with patch.object(detail_mod, "_build_detail_session", build):
result = await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=caller)
assert result is _SENTINEL
assert build.call_count == 1 # один reconnect перед 200
assert caller.get.await_count == n429 # short-budget + исчерпавший 429
caller.close.assert_not_called() # caller-provided session НЕ закрывается
retry1.close.assert_awaited() # эфемерная retry-session закрыта в finally
@pytest.mark.asyncio
async def test_429_fully_exhausted_raises_rate_limited(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""429 на short-retry budget + всех reconnect-попытках → AvitoRateLimitedError."""
_enable_backconnect(monkeypatch)
# short-budget+1 (исчерпание short) на caller; затем каждая reconnect-сессия тоже 429.
n429 = detail_mod._AVITO_DETAIL_429_MAX_RETRIES + 1
caller = AsyncMock()
caller.get = AsyncMock(side_effect=[_resp(429)] * n429)
def _make_retry() -> AsyncMock:
s = AsyncMock()
s.get = AsyncMock(return_value=_resp(429))
return s
build = MagicMock(side_effect=lambda: _make_retry())
with patch.object(detail_mod, "_build_detail_session", build):
with pytest.raises(AvitoRateLimitedError, match="HTTP 429"):
await fetch_detail("/x", cffi_session=caller)
# Все reconnect-попытки исчерпаны.
assert build.call_count == detail_mod._AVITO_DETAIL_429_RECONNECT_RETRIES
caller.close.assert_not_called()
@pytest.mark.asyncio
async def test_429_short_retry_then_200_no_reconnect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""429 → 200 в рамках short-retry budget: та же сессия, БЕЗ reconnect (regression guard)."""
_enable_backconnect(monkeypatch)
caller = AsyncMock()
caller.get = AsyncMock(side_effect=[_resp(429), _resp(200, "<html/>")])
build = MagicMock()
with patch.object(detail_mod, "_build_detail_session", build):
result = await fetch_detail("/x", cffi_session=caller)
assert result is _SENTINEL
build.assert_not_called() # short-retry успешен → reconnect не дёрнут
assert caller.get.await_count == 2 # та же сессия переиспользована
caller.close.assert_not_called()
@pytest.mark.asyncio
async def test_no_proxy_429_short_exhausted_raises_immediately(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""scraper_proxy_url unset → нет reconnect-пути → исчерпание short-retry сразу raise."""
_disable_backconnect(monkeypatch)
n429 = detail_mod._AVITO_DETAIL_429_MAX_RETRIES + 1
caller = AsyncMock()
caller.get = AsyncMock(side_effect=[_resp(429)] * n429)
build = MagicMock()
with patch.object(detail_mod, "_build_detail_session", build):
with pytest.raises(AvitoRateLimitedError, match="HTTP 429"):
await fetch_detail("/x", cffi_session=caller)
build.assert_not_called() # no proxy → нет reconnect
assert caller.get.await_count == n429