fix(avito): graceful sidecar HTTP 500/503 in browser SERP fetch (#1790)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

_fetch_serp_html_browser вызывал BrowserFetcher.fetch без обёртки, поэтому
httpx.HTTPStatusError от tradein-browser sidecar (500 NS_ERROR_PROXY_TOO_MANY_REQUESTS
/ Page.goto Timeout, 503 browser-unavailable) всплывал в generic except
run_avito_full_load → mark_failed + re-raise, роняя весь обход после тысяч
инкрементально сохранённых карточек (прод run #356: 3186 сохранено, один поздний
500 → весь run failed).

Классифицируем тело sidecar-ошибки и роутим в существующую rotation/retry-машинерию:
soft-ban (too_many_requests / 503 / browser unavailable) → ротация exit-IP;
transient (timeout / прочие 5xx) → bounded backoff-retry без ротации; оба бюджета
исчерпаны → AvitoRateLimitedError, чтобы run ушёл в graceful mark_banned
(partial preserved, scheduler retry next cycle), а не mark_failed.

Часть 1 issue #1790 (AVITO_PROXY_MAX_ROTATIONS=0→4) уже исправлена на проде;
это browser-resilience follow-up.

Refs #1790
This commit is contained in:
Light1YT 2026-06-25 11:45:16 +05:00
parent 4bb09911b8
commit e65b4d44d5
2 changed files with 248 additions and 2 deletions

View file

@ -27,6 +27,7 @@ import asyncio
import inspect
import logging
import math
import random
import re
from collections.abc import Callable
from datetime import date, datetime, timedelta, timezone
@ -272,6 +273,18 @@ _AVITO_429_RECONNECT_RETRIES = 3
_AVITO_403_MAX_RETRIES = 5
_AVITO_403_BACKOFF_SEC = 2.0
# tradein-browser sidecar (POST /fetch) отдаёт HTTP 500 на ЛЮБУЮ внутреннюю
# ошибку страницы (тело {"error": "ErrorType: message"}) и HTTP 503 когда браузер
# не поднимается (прокси упал). Транзиентные сбои (Page.goto Timeout, Target
# closed, прочие NS_ERROR_/5xx) лечатся коротким backoff-retry того же url БЕЗ
# ротации IP; soft-ban (NS_ERROR_PROXY_TOO_MANY_REQUESTS / 503 / browser
# unavailable) — ротацией exit-IP. Когда ОБА бюджета исчерпаны → AvitoRateLimitedError,
# чтобы run_avito_full_load/run_avito_city_sweep ушли в graceful mark_banned путь
# (partial preserved), а не в generic except → mark_failed (прод-инцидент run #356:
# один поздний 500 ронял весь обход после 3186 сохранённых объявлений).
_AVITO_SIDECAR_TRANSIENT_RETRIES = 2
_AVITO_SIDECAR_TRANSIENT_BACKOFF_SEC = 2.0
# Per-bucket block tolerance в fetch_all_secondary: одиночный 429/блок на ОДНОМ
# (комнатность × ценовой брекет) бакете (напр. deep-pagination dense-бакета) НЕ должен
# ронять весь sweep — бакет скипается, обход продолжается. Только N ПОДРЯД заблокированных
@ -453,12 +466,86 @@ class AvitoScraper(BaseScraper):
доступен только если _cffi поднят (standalone __aenter__); в
pipeline-режиме с shared-browser _cffi=None fallback пропускается.
3. curl-fallback тоже firewall/пусто (или недоступен) AvitoBlockedError.
Отдельная ветка sidecar HTTP-ошибка (#356): BrowserFetcher пробрасывает
httpx.HTTPStatusError (500/503), если /fetch упал внутри (Page.goto Timeout,
NS_ERROR_PROXY_TOO_MANY_REQUESTS, браузер не поднялся). Раньше она всплывала
неперехваченной generic except в run_avito_full_load mark_failed +
re-raise, что роняло весь обход даже после тысяч инкрементально сохранённых
карточек. Теперь классифицируем тело ошибки и роутим в ту же
ротация+retry-машинерию: soft-ban ротация IP, transient bounded backoff,
оба бюджета исчерпаны AvitoRateLimitedError (graceful mark_banned).
"""
assert self._browser is not None
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
rot_done = 0
# Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/
# crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient)
# уменьшает свой счётчик → цикл всегда движется к условию исчерпания.
transient_left = _AVITO_SIDECAR_TRANSIENT_RETRIES
while True:
html = await self._browser.fetch(url)
try:
html = await self._browser.fetch(url)
except httpx.HTTPStatusError as exc:
# Тело sidecar-ошибки: {"error": "ErrorType: message"}. Парсим
# устойчиво — невалидный JSON не должен ронять обработку.
status = exc.response.status_code
try:
error_text = str(exc.response.json().get("error", ""))
except Exception:
error_text = exc.response.text
lowered = error_text.lower()
is_soft_ban = (
"too_many_requests" in lowered
or status == 503
or "browser unavailable" in lowered
or "proxy may be down" in lowered
)
if is_soft_ban:
# Soft proxy rate-limit / прокси недоступен → свежий exit-IP.
if rot_done < max_rot and await self._rotate_ip():
rot_done += 1
logger.info(
"avito page=%d sidecar soft-ban (status=%d) — "
"retry after rotation #%d: %s",
page,
status,
rot_done,
error_text[:200],
)
continue
else:
# Transient (Page.goto Timeout / Target closed / прочие 5xx) →
# короткий backoff-retry того же url БЕЗ ротации.
if transient_left > 0:
transient_left -= 1
backoff = _AVITO_SIDECAR_TRANSIENT_BACKOFF_SEC + random.uniform(0, 0.5)
logger.warning(
"avito page=%d sidecar transient (status=%d) — "
"retry in %.1fs (left=%d): %s",
page,
status,
backoff,
transient_left,
error_text[:200],
)
await asyncio.sleep(backoff)
continue
# Оба бюджета (ротация + transient) исчерпаны → graceful soft-ban,
# чтобы run_avito_full_load/city_sweep ушли в mark_banned (partial
# preserved), а НЕ в generic except → mark_failed. Намеренно
# AvitoRateLimitedError, не AvitoBlockedError и не bare exception.
logger.error(
"avito page=%d sidecar error budget exhausted (status=%d) url=%s: %s",
page,
status,
url,
error_text[:200],
)
raise AvitoRateLimitedError(
f"avito SERP browser-sidecar error (page={page}): {error_text[:200]}"
) from exc
if not _is_firewall_page(html):
return html
if rot_done < max_rot and await self._rotate_ip():

View file

@ -15,12 +15,13 @@ from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services.scrapers.avito import _FIREWALL_MARKERS, AvitoScraper
from app.services.scrapers.avito_exceptions import AvitoBlockedError
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
from app.services.scrapers.base import ScrapedLot
# ---------------------------------------------------------------------------
@ -31,6 +32,18 @@ _NORMAL_HTML = "<html><body>normal avito SERP</body></html>"
_FIREWALL_HTML = f"<html><head><title>{_FIREWALL_MARKERS[0]}</title></head></html>"
def _sidecar_error(status: int, error: str) -> httpx.HTTPStatusError:
"""Build a tradein-browser sidecar HTTPStatusError with a real JSON body.
Mirrors what BrowserFetcher.fetch re-raises: an httpx.HTTPStatusError whose
`.response` is intact so `.response.json()["error"]` and `.response.status_code`
work the same as in production.
"""
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
response = httpx.Response(status, json={"error": error}, request=request)
return httpx.HTTPStatusError(f"{status} error", request=request, response=response)
def _make_mock_browser(html: str = _NORMAL_HTML) -> MagicMock:
"""Return a mock BrowserFetcher context manager that yields html from fetch()."""
mock = MagicMock()
@ -358,3 +371,149 @@ async def test_browser_mode_rotation_returns_false_raises_blocked() -> None:
with patch.object(scraper, "_rotate_ip", new=AsyncMock(return_value=False)):
with pytest.raises(AvitoBlockedError, match="browser-mode"):
await scraper._fetch_serp_html("https://avito.ru/test", page=1)
# ---------------------------------------------------------------------------
# 6. #356 — sidecar HTTPStatusError (500/503) → graceful AvitoRateLimitedError
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sidecar_proxy_too_many_requests_rotates_then_succeeds() -> None:
"""#356: sidecar 500 NS_ERROR_PROXY_TOO_MANY_REQUESTS, rotation available →
after IP rotation a retry succeeds and the clean HTML is returned."""
scraper = AvitoScraper()
mock_browser = MagicMock()
# First fetch raises the soft-ban 500; after rotation the second fetch is clean.
mock_browser.fetch = AsyncMock(
side_effect=[
_sidecar_error(500, "Error: Page.goto: NS_ERROR_PROXY_TOO_MANY_REQUESTS"),
_NORMAL_HTML,
]
)
scraper._browser = mock_browser
mock_rotate = AsyncMock(return_value=True)
with patch("app.services.scrapers.avito.settings") as mock_settings:
mock_settings.avito_proxy_rotate_url = "http://proxy/rotate"
mock_settings.avito_proxy_max_rotations = 2
with patch.object(scraper, "_rotate_ip", new=mock_rotate):
result = await scraper._fetch_serp_html("https://www.avito.ru/test", page=3)
assert result == _NORMAL_HTML
mock_rotate.assert_awaited_once()
assert mock_browser.fetch.await_count == 2
@pytest.mark.asyncio
async def test_sidecar_proxy_too_many_requests_exhausted_raises_rate_limited() -> None:
"""#356: sidecar 500 proxy-too-many with rotation budget exhausted →
AvitoRateLimitedError (NOT a bare HTTPStatusError, NOT AvitoBlockedError)."""
scraper = AvitoScraper()
mock_browser = MagicMock()
mock_browser.fetch = AsyncMock(
side_effect=_sidecar_error(500, "Error: Page.goto: NS_ERROR_PROXY_TOO_MANY_REQUESTS")
)
scraper._browser = mock_browser
mock_rotate = AsyncMock(return_value=True)
with patch("app.services.scrapers.avito.settings") as mock_settings:
mock_settings.avito_proxy_rotate_url = "http://proxy/rotate"
mock_settings.avito_proxy_max_rotations = 2
with patch.object(scraper, "_rotate_ip", new=mock_rotate):
with pytest.raises(AvitoRateLimitedError, match="browser-sidecar"):
await scraper._fetch_serp_html("https://www.avito.ru/test", page=3)
# 2 rotations consumed + 1 final fetch that exhausts the budget.
assert mock_rotate.await_count == 2
assert mock_browser.fetch.await_count == 3
@pytest.mark.asyncio
async def test_sidecar_transient_timeout_exhausts_budget_no_infinite_loop() -> None:
"""#356: sidecar 500 transient (Timeout) repeated past the transient budget with
NO rotations AvitoRateLimitedError after bounded retries (no infinite loop)."""
scraper = AvitoScraper()
mock_browser = MagicMock()
mock_browser.fetch = AsyncMock(
side_effect=_sidecar_error(500, "TimeoutError: Page.goto: Timeout 60000ms exceeded")
)
scraper._browser = mock_browser
mock_rotate = AsyncMock(return_value=True)
with patch("app.services.scrapers.avito.settings") as mock_settings:
mock_settings.avito_proxy_rotate_url = None # no rotation → transient budget only
mock_settings.avito_proxy_max_rotations = 0
# Patch sleep so the bounded backoff doesn't slow the test.
with patch("app.services.scrapers.avito.asyncio.sleep", new=AsyncMock()):
with patch.object(scraper, "_rotate_ip", new=mock_rotate):
with pytest.raises(AvitoRateLimitedError, match="browser-sidecar"):
await scraper._fetch_serp_html("https://www.avito.ru/test", page=5)
# Transient is NOT a soft-ban → never rotates.
mock_rotate.assert_not_awaited()
# Bounded: _AVITO_SIDECAR_TRANSIENT_RETRIES (2) retries + final attempt = 3.
assert mock_browser.fetch.await_count == 3
@pytest.mark.asyncio
async def test_sidecar_503_browser_unavailable_exhausted_raises_rate_limited() -> None:
"""#356: sidecar 503 'browser unavailable (proxy may be down)' with rotation
exhausted AvitoRateLimitedError (soft-ban classification via status==503)."""
scraper = AvitoScraper()
mock_browser = MagicMock()
mock_browser.fetch = AsyncMock(
side_effect=_sidecar_error(503, "browser unavailable (proxy may be down)")
)
scraper._browser = mock_browser
mock_rotate = AsyncMock(return_value=True)
with patch("app.services.scrapers.avito.settings") as mock_settings:
mock_settings.avito_proxy_rotate_url = "http://proxy/rotate"
mock_settings.avito_proxy_max_rotations = 1
with patch.object(scraper, "_rotate_ip", new=mock_rotate):
with pytest.raises(AvitoRateLimitedError, match="browser-sidecar"):
await scraper._fetch_serp_html("https://www.avito.ru/test", page=7)
# 1 rotation consumed then exhausted.
assert mock_rotate.await_count == 1
assert mock_browser.fetch.await_count == 2
@pytest.mark.asyncio
async def test_sidecar_transient_then_soft_ban_independent_budgets() -> None:
"""#356: смешанная последовательность transient → soft-ban пинит, что бюджеты
независимы и корректно чередуются в одном while-цикле.
attempt 1: transient Timeout тратит transient_left (1 из 2), backoff, continue.
attempt 2: too_many_requests soft-ban, тратит ротацию (1 из 1), continue.
attempt 3: too_many_requests, ротация исчерпана AvitoRateLimitedError.
"""
scraper = AvitoScraper()
mock_browser = MagicMock()
mock_browser.fetch = AsyncMock(
side_effect=[
_sidecar_error(500, "TimeoutError: Page.goto: Timeout 60000ms exceeded"),
_sidecar_error(500, "Error: Page.goto: NS_ERROR_PROXY_TOO_MANY_REQUESTS"),
_sidecar_error(500, "Error: Page.goto: NS_ERROR_PROXY_TOO_MANY_REQUESTS"),
]
)
scraper._browser = mock_browser
mock_rotate = AsyncMock(return_value=True)
with patch("app.services.scrapers.avito.settings") as mock_settings:
mock_settings.avito_proxy_rotate_url = "http://proxy/rotate"
mock_settings.avito_proxy_max_rotations = 1
with patch("app.services.scrapers.avito.asyncio.sleep", new=AsyncMock()):
with patch.object(scraper, "_rotate_ip", new=mock_rotate):
with pytest.raises(AvitoRateLimitedError, match="browser-sidecar"):
await scraper._fetch_serp_html("https://www.avito.ru/test", page=9)
# 1 transient retry + 1 rotation, оба бюджета тронуты до исчерпания.
assert mock_rotate.await_count == 1
assert mock_browser.fetch.await_count == 3