fix(tradein/avito): catch sidecar timeout/transport errors in SERP fetch → graceful banned, not crash (#2523)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / deploy (push) Successful in 7m48s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / deploy (push) Successful in 7m48s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
e61c2debe5
commit
c61ed5a96e
2 changed files with 75 additions and 15 deletions
|
|
@ -278,3 +278,45 @@ async def test_fetch_serp_html_routes_through_browser() -> None:
|
|||
|
||||
assert html == "<html><body>real serp listing</body></html>"
|
||||
scraper._browser.fetch.assert_awaited_once()
|
||||
|
||||
|
||||
# ── 6. SERP browser sidecar timeout → transient retry, not bare exception ────
|
||||
# #2472: BrowserFetcher.fetch (browser_fetcher.py) retries once internally on
|
||||
# httpx.HTTPError/TransportError, then re-raises. Before this fix, non-
|
||||
# HTTPStatusError exceptions (httpx.TimeoutException/TransportError — e.g.
|
||||
# ReadTimeout/ConnectError/RemoteProtocolError) propagated unhandled through
|
||||
# _fetch_serp_html_browser straight into pipeline.run_avito_full_load's generic
|
||||
# `except Exception` → mark_failed(str(exc)) (str(ReadTimeout())=="" → empty
|
||||
# error, and mark_failed does NOT persist done_buckets, unlike mark_banned).
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_serp_html_browser_timeout_routes_transient_not_bare_exception() -> None:
|
||||
"""httpx.ReadTimeout from browser.fetch() must NOT propagate as an unhandled
|
||||
exception. It's classified transient (status=0, no soft-ban keyword match)
|
||||
and, once the bounded retry budget is exhausted, surfaces as
|
||||
AvitoRateLimitedError — the same graceful banned-path a sidecar
|
||||
HTTPStatusError takes — never as a bare/generic exception."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from scraper_kit.avito_exceptions import AvitoRateLimitedError
|
||||
from scraper_kit.providers.avito import serp as serp_module
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
scraper = AvitoScraper(RealScraperConfig())
|
||||
assert scraper._cffi is None
|
||||
scraper._browser = AsyncMock()
|
||||
scraper._browser.fetch = AsyncMock(side_effect=httpx.ReadTimeout(""))
|
||||
|
||||
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
|
||||
with pytest.raises(AvitoRateLimitedError):
|
||||
await scraper._fetch_serp_html(
|
||||
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
|
||||
)
|
||||
|
||||
# 1 initial attempt + 2 transient retries (_AVITO_SIDECAR_TRANSIENT_RETRIES) = 3,
|
||||
# no IP rotation involved (RealScraperConfig has no rotate_url configured in tests).
|
||||
assert scraper._browser.fetch.await_count == 3
|
||||
|
|
|
|||
|
|
@ -485,14 +485,19 @@ class AvitoScraper(BaseScraper):
|
|||
pipeline-режиме с shared-browser _cffi=None → fallback пропускается.
|
||||
3. curl-fallback тоже firewall/пусто (или недоступен) → AvitoBlockedError.
|
||||
|
||||
Отдельная ветка — sidecar HTTP-ошибка (#356): BrowserFetcher пробрасывает
|
||||
Отдельная ветка — sidecar-ошибка (#356, #2472): 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).
|
||||
NS_ERROR_PROXY_TOO_MANY_REQUESTS, браузер не поднялся), либо
|
||||
httpx.TimeoutException/httpx.TransportError (ReadTimeout/ConnectError/
|
||||
RemoteProtocolError), если соединение до sidecar оборвалось ДО ответа (после
|
||||
internal-retry в BrowserFetcher.fetch). Раньше обе ветки (а до #2472 — только
|
||||
не-HTTPStatusError) всплывали неперехваченными → generic except в
|
||||
run_avito_full_load → mark_failed + re-raise, что роняло весь обход даже после
|
||||
тысяч инкрементально сохранённых карточек (и теряло done_buckets-чекпоинт —
|
||||
mark_failed его не пишет). Теперь классифицируем тело ошибки (HTTP-ошибки) или
|
||||
просто помечаем как transient (не-HTTP) и роутим в ту же ротация+retry-
|
||||
машинерию: soft-ban → ротация IP, transient → bounded backoff, оба бюджета
|
||||
исчерпаны → AvitoRateLimitedError (graceful mark_banned, partial preserved).
|
||||
"""
|
||||
assert self._browser is not None
|
||||
max_rot = self._config.avito_proxy_max_rotations if self._config.avito_proxy_rotate_url else 0
|
||||
|
|
@ -504,14 +509,27 @@ class AvitoScraper(BaseScraper):
|
|||
while True:
|
||||
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
|
||||
except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
# HTTPStatusError: тело sidecar-ошибки {"error": "ErrorType: message"}.
|
||||
# Парсим устойчиво — невалидный JSON не должен ронять обработку.
|
||||
#
|
||||
# TimeoutException/TransportError (ReadTimeout/ConnectError/
|
||||
# RemoteProtocolError): сеть до sidecar оборвалась ДО ответа (после
|
||||
# internal-retry BrowserFetcher.fetch, browser_fetcher.py) — HTTP-статуса
|
||||
# нет. Раньше это вываливалось необработанным через
|
||||
# _paginate_incremental_bracket в generic except run_avito_full_load →
|
||||
# mark_failed(str(exc)) — а str(ReadTimeout())=="" (пустой error) теряет
|
||||
# done_buckets чекпоинт (mark_failed его не пишет, в отличие от mark_banned).
|
||||
# Роутим как transient (status=0, не soft-ban) — симметрично _probe_total.
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
status = exc.response.status_code
|
||||
try:
|
||||
error_text = str(exc.response.json().get("error", ""))
|
||||
except Exception:
|
||||
error_text = exc.response.text
|
||||
else:
|
||||
status = 0
|
||||
error_text = f"{type(exc).__name__}: {exc}"
|
||||
lowered = error_text.lower()
|
||||
|
||||
is_soft_ban = (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue