fix(tradein/domclick): бан сайдкара наследуется от httpx-ошибки и уезжал в транспортные сбои (#3283)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s

Правка #3286 разделила отказы площадки и сбои нашей стороны по природе
исключения: httpx-ошибка в цепочке причин = транспорт. Прод показал дыру
на первом же прогоне (5406):

  СБОЙ #1/400 (подряд=1/10, http=401, kind=platform, не блок площадки):
  DomClick detail: sidecar detected platform refusal

Заголовок «не блок площадки» стоит над записью, где kind=platform и код 401,
то есть над самым что ни на есть отказом площадки. Причина в иерархии:
SidecarBanPageError объявлен как подкласс httpx.HTTPStatusError, поэтому
проверка на httpx, стоявшая первой, забирала генуинный бан себе.

Порядок проверок перевёрнут: бан сайдкара отсекается ДО общей httpx-ветки.
Остальное поведение #3286 не тронуто — обычная 500 от сайдкара по-прежнему
транспортный сбой.

Тесты: +4, включая закрепление самой предпосылки (issubclass проверяется
явно, чтобы смена иерархии не отключила проверку молча) и страховку, что
обычная 500 осталась сбоем. Проверено мутацией: снятие порядка роняет тест
и печатает самопротиворечивый лог «10 сбоев подряд без единого отказа
площадки, диагнозы: {'platform': 10}». Набор целиком — 5210 passed.
This commit is contained in:
bot-backend 2026-08-30 17:02:46 +03:00
parent 03d04f13b5
commit 1e02badbe9
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