Merge pull request 'fix(avito): reconnect-retry on detail 429 under backconnect proxy' (#1771) from fix/avito-detail-429-reconnect into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m18s
Deploy Trade-In / build-backend (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 1m0s

Reviewed-on: #1771
This commit is contained in:
lekss361 2026-06-19 04:42:11 +00:00
commit cf6dc59cb6
2 changed files with 186 additions and 3 deletions

View file

@ -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}")

View file

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