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