fix(avito): graceful sidecar HTTP 500/503 in browser SERP fetch (#1790)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 58s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Successful in 1m25s
Deploy Trade-In / build-backend (push) Successful in 2m0s

Classify tradein-browser sidecar HTTP 500/503 in _fetch_serp_html_browser and route into existing rotation/retry machinery: soft-ban → IP rotation, transient → bounded backoff, both exhausted → AvitoRateLimitedError (graceful mark_banned, partial preserved) instead of mark_failed. Fixes run-#356 class where one late 500 nuked a run that had saved 3186 listings.

Refs #1790
This commit is contained in:
bot-backend 2026-06-25 06:47:41 +00:00
parent 4bb09911b8
commit 4eaf184d0e
2 changed files with 248 additions and 2 deletions

View file

@ -27,6 +27,7 @@ import asyncio
import inspect import inspect
import logging import logging
import math import math
import random
import re import re
from collections.abc import Callable from collections.abc import Callable
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
@ -272,6 +273,18 @@ _AVITO_429_RECONNECT_RETRIES = 3
_AVITO_403_MAX_RETRIES = 5 _AVITO_403_MAX_RETRIES = 5
_AVITO_403_BACKOFF_SEC = 2.0 _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/блок на ОДНОМ # Per-bucket block tolerance в fetch_all_secondary: одиночный 429/блок на ОДНОМ
# (комнатность × ценовой брекет) бакете (напр. deep-pagination dense-бакета) НЕ должен # (комнатность × ценовой брекет) бакете (напр. deep-pagination dense-бакета) НЕ должен
# ронять весь sweep — бакет скипается, обход продолжается. Только N ПОДРЯД заблокированных # ронять весь sweep — бакет скипается, обход продолжается. Только N ПОДРЯД заблокированных
@ -453,12 +466,86 @@ class AvitoScraper(BaseScraper):
доступен только если _cffi поднят (standalone __aenter__); в доступен только если _cffi поднят (standalone __aenter__); в
pipeline-режиме с shared-browser _cffi=None fallback пропускается. pipeline-режиме с shared-browser _cffi=None fallback пропускается.
3. curl-fallback тоже firewall/пусто (или недоступен) AvitoBlockedError. 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 assert self._browser is not None
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0 max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
rot_done = 0 rot_done = 0
# Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/
# crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient)
# уменьшает свой счётчик → цикл всегда движется к условию исчерпания.
transient_left = _AVITO_SIDECAR_TRANSIENT_RETRIES
while True: 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): if not _is_firewall_page(html):
return html return html
if rot_done < max_rot and await self._rotate_ip(): if rot_done < max_rot and await self._rotate_ip():

View file

@ -15,12 +15,13 @@ from __future__ import annotations
import os import os
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") 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 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 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>" _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: def _make_mock_browser(html: str = _NORMAL_HTML) -> MagicMock:
"""Return a mock BrowserFetcher context manager that yields html from fetch().""" """Return a mock BrowserFetcher context manager that yields html from fetch()."""
mock = MagicMock() 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 patch.object(scraper, "_rotate_ip", new=AsyncMock(return_value=False)):
with pytest.raises(AvitoBlockedError, match="browser-mode"): with pytest.raises(AvitoBlockedError, match="browser-mode"):
await scraper._fetch_serp_html("https://avito.ru/test", page=1) 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