"""curl_cffi 403/firewall reconnect-retry на detail-странице под backconnect-прокси. fetch_detail ронял весь detail-backfill на первом 403/429 через AvitoBlockedError / AvitoRateLimitedError (run 191 обогатил 48 → zombie). Зеркало SERP-фикса (#1765): под backconnect rotating-прокси (mproxy.site) каждое НОВОЕ соединение даёт свежий exit-IP, поэтому 403/firewall ретраим пересозданием эфемерной curl_cffi-сессии до _AVITO_DETAIL_403_MAX_RETRIES раз. 429 — короткий retry той же сессии без reconnect. Ownership-правило: caller-provided cffi_session НИКОГДА не закрывается/не пересоздаётся — retries идут через свою эфемерную retry_session (закрываем в finally). Включаем backconnect через scraper_proxy_url_env (scraper_proxy_url — read-only property, его не monkeypatch'ишь). Зеркало fixture-стиля test_avito_403_backconnect_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 AvitoBlockedError _SENTINEL = object() _FIREWALL_HTML = ( "Доступ ограничен: проблема с IP" '
...
' ) 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_403_403_200_caller_session_reconnect_succeeds( monkeypatch: pytest.MonkeyPatch, ) -> None: """caller-provided session: 403 → 403 → 200 → reconnect-retry возвращает enrichment. _build_detail_session дёрнут для retries; caller-session НЕ закрыта. """ _enable_backconnect(monkeypatch) caller = AsyncMock() caller.get = AsyncMock(return_value=_resp(403)) # Каждый retry → свежая эфемерная сессия. Первая отдаёт 403 (для 2-го reconnect), # вторая — 200. retry1 = AsyncMock() retry1.get = AsyncMock(return_value=_resp(403)) retry2 = AsyncMock() retry2.get = AsyncMock(return_value=_resp(200, "")) build = MagicMock(side_effect=[retry1, retry2]) 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 == 2 # 403, 403 → два reconnect перед 200 caller.close.assert_not_called() # caller-provided session НЕ закрывается assert caller.get.await_count == 1 # первый attempt retry1.close.assert_awaited() # промежуточная retry-session закрыта retry2.close.assert_awaited() # последняя retry-session закрыта в finally @pytest.mark.asyncio async def test_firewall_200_reconnect_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: """firewall(HTTP 200) → 200 → новый HTTP-200 firewall-detect триггерит reconnect.""" _enable_backconnect(monkeypatch) caller = AsyncMock() caller.get = AsyncMock(return_value=_resp(200, _FIREWALL_HTML)) 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("/x", cffi_session=caller) assert result is _SENTINEL assert build.call_count == 1 caller.close.assert_not_called() @pytest.mark.asyncio async def test_403_exhausted_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None: """403 _AVITO_DETAIL_403_MAX_RETRIES+1 раз → AvitoBlockedError.""" _enable_backconnect(monkeypatch) caller = AsyncMock() caller.get = AsyncMock(return_value=_resp(403)) def _make_retry() -> AsyncMock: s = AsyncMock() s.get = AsyncMock(return_value=_resp(403)) return s build = MagicMock(side_effect=lambda: _make_retry()) with patch.object(detail_mod, "_build_detail_session", build): with pytest.raises(AvitoBlockedError, match="HTTP 403"): await fetch_detail("/x", cffi_session=caller) assert build.call_count == detail_mod._AVITO_DETAIL_403_MAX_RETRIES caller.close.assert_not_called() @pytest.mark.asyncio async def test_429_short_retry_then_200_same_session( monkeypatch: pytest.MonkeyPatch, ) -> None: """429 → 429 → 200: short-retry той же сессии, без reconnect.""" _enable_backconnect(monkeypatch) caller = AsyncMock() caller.get = AsyncMock(side_effect=[_resp(429), _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() # 429 не дёргает reconnect assert caller.get.await_count == 3 # та же сессия переиспользована caller.close.assert_not_called() @pytest.mark.asyncio async def test_no_proxy_single_403_raises_immediately( monkeypatch: pytest.MonkeyPatch, ) -> None: """scraper_proxy_url unset → нет reconnect-пути → первый 403 сразу raise.""" _disable_backconnect(monkeypatch) caller = AsyncMock() caller.get = AsyncMock(return_value=_resp(403)) build = MagicMock() with patch.object(detail_mod, "_build_detail_session", build): with pytest.raises(AvitoBlockedError, match="HTTP 403"): await fetch_detail("/x", cffi_session=caller) build.assert_not_called() assert caller.get.await_count == 1 @pytest.mark.asyncio async def test_own_session_403_200_closes_both(monkeypatch: pytest.MonkeyPatch) -> None: """own-session path (cffi_session=None): 403 → 200; own session + retry session обе закрыты в finally.""" _enable_backconnect(monkeypatch) own = AsyncMock() own.get = AsyncMock(return_value=_resp(403)) retry1 = AsyncMock() retry1.get = AsyncMock(return_value=_resp(200, "")) # Первый _build_detail_session → own (own_session-ветка), второй → retry1 (reconnect). build = MagicMock(side_effect=[own, retry1]) with patch.object(detail_mod, "_build_detail_session", build): result = await fetch_detail("/x", cffi_session=None) assert result is _SENTINEL own.close.assert_awaited() # own session закрыта (own_session=True) retry1.close.assert_awaited() # retry session закрыта в finally assert build.call_count == 2