fix(tradein/domclick): бан сайдкара наследуется от httpx-ошибки и уезжал в транспортные сбои (#3283) #3287

Merged
lekss361 merged 1 commit from fix/3283b-sidecar-ban-is-httpx-subclass into main 2026-08-30 20:25:24 +00:00
2 changed files with 71 additions and 4 deletions

View file

@ -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:

View file

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