From 1e02badbe9e435566630353e0f0bf9ec5999d4ce Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 17:02:46 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20=D0=B1=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=81=D0=B0=D0=B9=D0=B4=D0=BA=D0=B0=D1=80=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B4=D1=83=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=82=20httpx-=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B5=D0=B7=D0=B6=D0=B0=D0=BB=20=D0=B2=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D0=B8=20(#3283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правка #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. --- .../app/tasks/domclick_detail_backfill.py | 19 +++++-- .../test_3283_non_blocks_counted_as_blocks.py | 56 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) 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 -- 2.45.3