From d65a88f7043e1a554b176bfb4709a255ba2c433e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 18 Jun 2026 23:29:47 +0300 Subject: [PATCH] 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). --- .../app/services/scrapers/avito_detail.py | 36 ++++- .../test_avito_detail_429_reconnect.py | 153 ++++++++++++++++++ 2 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 tradein-mvp/backend/tests/scrapers/test_avito_detail_429_reconnect.py diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index 0b2726c1..5c02432f 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -45,13 +45,18 @@ logger = logging.getLogger(__name__) AVITO_BASE = "https://www.avito.ru" # Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса -# (#1765, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси; +# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси; # пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape. -# 429 = transient conn-limit, лечится коротким retry на той же сессии (без reconnect). +# 429 = transient conn-limit: сначала короткий retry на ТОЙ ЖЕ сессии. Но keep-alive +# detail-fetch переиспользует ОДИН backconnect exit-IP на последовательные запросы → +# avito лочит этот IP, и short-retry той же сессии не помогает (run 217: 5 подряд 429, +# abort на 19 enriched). Поэтому при исчерпании short-retry под backconnect делаем ещё +# _AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP). _AVITO_DETAIL_403_MAX_RETRIES = 5 _AVITO_DETAIL_403_BACKOFF_SEC = 2.0 -_AVITO_DETAIL_429_MAX_RETRIES = 4 +_AVITO_DETAIL_429_MAX_RETRIES = 8 _AVITO_DETAIL_429_BACKOFF_SEC = 1.5 +_AVITO_DETAIL_429_RECONNECT_RETRIES = 3 # ── Русские месяцы ───────────────────────────────────────────────────────── RUS_MONTHS: dict[str, int] = { @@ -315,6 +320,7 @@ async def fetch_detail( backconnect = bool(settings.scraper_proxy_url) r403 = 0 r429 = 0 + r429_reconnect = 0 attempt_session = session retry_session: AsyncSession | None = None try: @@ -358,6 +364,30 @@ async def fetch_detail( raise AvitoBlockedError(f"Avito detail HTTP 403 for {full_url}") if sc == 429: + # short-retry (та же сессия) исчерпан. Под backconnect — ещё несколько + # попыток через эфемерную сессию (свежий exit-IP): keep-alive держал ОДИН + # залоченный IP, новый CONNECT-туннель уходит на другой. Делит ту же + # retry_session-машинерию что и 403-путь (close-prev → rebuild → retry, + # cleanup в finally). Только потом raise. + if backconnect and r429_reconnect < _AVITO_DETAIL_429_RECONNECT_RETRIES: + r429_reconnect += 1 + # Caller-provided/own session НЕ пересоздаём — только retry_session. + if retry_session is not None: + try: + await retry_session.close() + except Exception: + pass + retry_session = _build_detail_session() + attempt_session = retry_session + logger.info( + "avito detail HTTP 429 short-retry exhausted %s — " + "backconnect reconnect retry %d/%d", + full_url, + r429_reconnect, + _AVITO_DETAIL_429_RECONNECT_RETRIES, + ) + await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC) + continue raise AvitoRateLimitedError(f"Avito detail HTTP 429 for {full_url}") if sc != 200: raise ValueError(f"avito detail HTTP {sc} for {full_url}") diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_429_reconnect.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_429_reconnect.py new file mode 100644 index 00000000..3284c326 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_429_reconnect.py @@ -0,0 +1,153 @@ +"""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, "")) + 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, "")]) + 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 -- 2.45.3