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