diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index c65fdd7d..a6e4a11e 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -91,7 +91,11 @@ from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta import httpx -from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status +from scraper_kit.browser_fetcher import ( + BrowserFetcher, + SidecarBanPageError, + ban_kind_from_status, +) from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError from scraper_kit.providers.domclick.detail import fetch_detail, save_detail_enrichment from scraper_kit.proxy_errors import NoProxyAvailableError @@ -221,10 +225,17 @@ def _is_transport_failure(exc: BaseException) -> bool: * ветка `except Exception` — таймаут / 5xx сайдкара / транспорт, обёрнутый `raise ... from exc`. Исходное исключение остаётся в `__cause__`. - Смотрим именно на третий случай: httpx-ошибка в цепочке причин. Отсутствие - статуса признаком служить не может — им как раз отличается генуинный блок. + ПОРЯДОК ПРОВЕРОК ЗДЕСЬ НЕСЛУЧАЕН. `SidecarBanPageError` объявлен как + `class SidecarBanPageError(httpx.HTTPStatusError)`, то есть ПО ТИПУ он + httpx-ошибка — и проверка на httpx, стоящая первой, объявляла генуинный + отказ площадки транспортным сбоем. Поймано на проде: прогон 5406 записал + `http=401, kind=platform` под заголовком «не блок площадки». Поэтому бан + сайдкара отсекается ДО общей httpx-ветки, а не после. """ - return any(isinstance(c, httpx.HTTPError) for c in _iter_causes(exc)) + causes = _iter_causes(exc) + if any(isinstance(c, SidecarBanPageError) for c in causes): + return False + return any(isinstance(c, httpx.HTTPError) for c in causes) def _ban_kind_of_block(exc: DomClickBlockedError) -> str: diff --git a/tradein-mvp/backend/tests/tasks/test_3283_non_blocks_counted_as_blocks.py b/tradein-mvp/backend/tests/tasks/test_3283_non_blocks_counted_as_blocks.py index b0881130..ed6aab68 100644 --- a/tradein-mvp/backend/tests/tasks/test_3283_non_blocks_counted_as_blocks.py +++ b/tradein-mvp/backend/tests/tasks/test_3283_non_blocks_counted_as_blocks.py @@ -202,3 +202,59 @@ async def test_success_resets_the_soft_streak() -> None: result, _ = await _run(seq, n=18, max_consecutive_soft_failures=4) assert result.attempted == 18 assert result.enriched == 9 + + +# ── SidecarBanPageError — httpx-ошибка ПО ТИПУ, но генуинный отказ по смыслу ── +# Поймано на проде уже ПОСЛЕ первой правки: прогон 5406 записал отказ площадки +# (http=401, kind=platform) под заголовком «не блок площадки». Причина — +# `class SidecarBanPageError(httpx.HTTPStatusError)`: проверка на httpx, стоящая +# первой, забирала генуинный бан себе. Тесты ниже закрепляют порядок проверок. + + +def _sidecar_ban_page() -> DomClickBlockedError: + """Форма ровно как в fetch_detail: DomClickBlockedError ← SidecarBanPageError.""" + from scraper_kit.browser_fetcher import SidecarBanPageError + + ban = SidecarBanPageError( + "tradein-browser[domclick]: статический отказ площадки", + request=httpx.Request("POST", "http://tradein-browser:3000/fetch"), + response=httpx.Response(500), + upstream_status=401, + ) + exc = DomClickBlockedError("DomClick detail: sidecar detected platform refusal", status=401) + exc.__cause__ = ban + return exc + + +def test_sidecar_ban_page_is_a_subclass_of_httpx_error() -> None: + """Предпосылка теста ниже — зафиксировать её, а не подразумевать. + + Если иерархия однажды изменится, тест скажет об этом прямо, а не молча + перестанет проверять то, ради чего написан. + """ + from scraper_kit.browser_fetcher import SidecarBanPageError + + assert issubclass(SidecarBanPageError, httpx.HTTPError) + + +async def test_sidecar_ban_page_counts_as_block_not_transport() -> None: + """Отказ, опознанный сайдкаром по маркерам тела, — блок площадки.""" + result, _ = await _run([_sidecar_ban_page()] * 10, n=10, max_consecutive_blocks=3) + assert result.blocked == 3 + assert result.failed == 0 + assert result.attempted == 3 + + +async def test_sidecar_ban_page_aborts_the_run() -> None: + """Три отказа площадки подряд обязаны рвать прогон — ровно как до #3283.""" + _, runs = await _run([_sidecar_ban_page()] * 10, n=10, max_consecutive_blocks=3) + runs.mark_backfill_finished.assert_called_once() + assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True + + +async def test_plain_sidecar_500_still_counts_as_transport() -> None: + """Обычная 500 от сайдкара остаётся сбоем: правка не должна отменить #3283.""" + result, _ = await _run([_sidecar_500()] * 5 + [None] * 5, n=10) + assert result.blocked == 0 + assert result.failed == 5 + assert result.enriched == 5