gendesign/tradein-mvp/backend/tests/test_2686_ban_kind_split.py
bot-backend 9e5e9fca08
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-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m58s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m44s
fix(tradein/scraper): статус «забанен» перестаёт выдавать наш сбой за чужой (#2686) (#2711)
2026-08-06 08:18:26 +00:00

246 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#2686: статус 'banned' перестаёт смешивать наш сбой с блокировкой площадкой.
Замер на проде (2026-08-06): из 115 avito-прогонов со статусом «забанен» 92 (80%) —
отказ НАШЕГО браузерного сайдкара («browser unavailable (proxy may be down)»), и
только 10 — реальный firewall площадки. По этому статусу приняли решение замедлить
avito_full_load_exhaustive более чем вдвое (миграция 206) — основание было ложным.
Различитель несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения), а не разбирается
из текста ошибки постфактум. Проверяем три звена цепочки:
1. serp.py поднимает AvitoSidecarUnavailableError там и только там, где виноваты мы;
2. pipeline.ban_kind_of_exception переводит тип в диагноз;
3. run_avito_full_load доносит диагноз до mark_banned — и при ЭТОМ сохраняет
done_buckets-чекпоинт (побочная функция 'banned', которую нельзя потерять).
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.avito_exceptions import (
AvitoBlockedError,
AvitoRateLimitedError,
AvitoSidecarUnavailableError,
)
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration.pipeline import (
ban_kind_of_exception,
run_avito_full_load,
)
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
PFX = "scraper_kit.orchestration.pipeline"
# Настоящий firewall-интерстициал Авито: _is_firewall_page ловит его по тексту.
_FIREWALL_HTML = "<html><body>Доступ ограничен: проблема с IP</body></html>"
def _sidecar_503() -> httpx.HTTPStatusError:
"""Ровно то, что прод писал 90 раз с 05.07 по 03.08."""
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
response = httpx.Response(
503, json={"error": "browser unavailable (proxy may be down)"}, request=request
)
return httpx.HTTPStatusError("503", request=request, response=response)
# ── 1. Место порождения ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_sidecar_failure_raises_own_infra_subtype() -> None:
"""503 сайдкара → AvitoSidecarUnavailableError, и он ОСТАЁТСЯ AvitoRateLimitedError.
Фальсификация: до правки поднимался голый AvitoRateLimitedError — первый assert
падает. Второй assert стережёт совместимость: все существующие
`except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить
этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт.
"""
scraper = AvitoScraper(RealScraperConfig())
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(side_effect=_sidecar_503())
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
with pytest.raises(AvitoSidecarUnavailableError) as excinfo:
await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1)
assert isinstance(excinfo.value, AvitoRateLimitedError)
@pytest.mark.asyncio
async def test_platform_firewall_is_not_our_infra() -> None:
"""Рендер firewall-страницы → AvitoBlockedError, и это НЕ наш инфра-подтип."""
scraper = AvitoScraper(RealScraperConfig())
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(return_value=_FIREWALL_HTML)
scraper._browser.report_ban = MagicMock() # sync-хук, не корутина
with pytest.raises(AvitoBlockedError) as excinfo:
await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1)
assert not isinstance(excinfo.value, AvitoSidecarUnavailableError)
# ── 2. Перевод типа в диагноз ────────────────────────────────────────────────
@pytest.mark.parametrize(
("exc", "expected"),
[
(AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA),
(AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM),
(AvitoRateLimitedError("HTTP 429"), kit_runs.BAN_KIND_PLATFORM),
(RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_PLATFORM),
],
)
def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None:
"""Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип."""
assert ban_kind_of_exception(exc) == expected
# ── 3. Диагноз доезжает до строки прогона, чекпоинт цел ──────────────────────
class _BanRecorder:
"""Минимальный двойник scrape_runs: интересуют ban_kind и counters у mark_banned."""
def __init__(self) -> None:
self.banned: list[tuple[str, dict[str, Any]]] = []
self.other: list[str] = []
def is_cancelled(self, db: Any, run_id: int) -> bool:
return False
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
pass
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
self.other.append("mark_done")
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.other.append("mark_failed")
def mark_banned(
self,
db: Any,
run_id: int,
error: str,
counters: dict[str, Any],
*,
ban_kind: str = kit_runs.BAN_KIND_PLATFORM,
) -> None:
self.banned.append((ban_kind, dict(counters)))
def _config() -> SimpleNamespace:
return SimpleNamespace(
scraper_fetch_mode="curl_cffi",
browser_http_endpoint="http://browser.test/fetch",
scraper_proxy_url=None,
avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
avito_full_load_price_cap=1400,
avito_full_load_max_pages=100,
avito_full_load_concurrency=1,
request_delay_sec=0.0,
scraper_skip_seen_today=False,
)
def _scraper_that_saves_one_bucket_then(exc: Exception) -> MagicMock:
"""Fake AvitoScraper: один бакет успешно отдан в on_bucket, затем отказ."""
async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
on_bucket("2к:0-5m", [MagicMock(source_id="a1")])
raise exc
m = MagicMock()
m.__aenter__ = AsyncMock(return_value=m)
m.__aexit__ = AsyncMock(return_value=None)
m.fetch_all_secondary = _fetch
m._browser = None
return m
async def _drive_full_load(exc: Exception) -> _BanRecorder:
recorder = _BanRecorder()
with (
patch(f"{PFX}.AvitoScraper", return_value=_scraper_that_saves_one_bucket_then(exc)),
patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
patch(f"{PFX}.runs", recorder),
):
await run_avito_full_load(MagicMock(), run_id=1, config=_config(), matcher=MagicMock())
return recorder
@pytest.mark.asyncio
async def test_full_load_sidecar_ban_is_infra_and_keeps_checkpoint() -> None:
"""Отказ нашего сайдкара → ban_kind='infra', done_buckets сохранён.
Фальсификация: до правки mark_banned не принимал ban_kind вовсе — вызов падал
бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы
'platform' (дефолт), то есть ровно то враньё, которое стоило миграции 206.
"""
recorder = await _drive_full_load(
AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)")
)
assert len(recorder.banned) == 1
ban_kind, counters = recorder.banned[0]
assert ban_kind == kit_runs.BAN_KIND_INFRA
assert counters["done_buckets"] == ["2к:0-5m"]
@pytest.mark.asyncio
async def test_full_load_platform_ban_keeps_the_same_checkpoint() -> None:
"""Блокировка площадкой → ban_kind='platform', чекпоинт сохраняется ТАК ЖЕ.
Второй исход не должен потерять побочную функцию 'banned' при разведении.
"""
recorder = await _drive_full_load(AvitoBlockedError("Avito SERP firewall — IP banned"))
assert len(recorder.banned) == 1
ban_kind, counters = recorder.banned[0]
assert ban_kind == kit_runs.BAN_KIND_PLATFORM
assert counters["done_buckets"] == ["2к:0-5m"]
# ── 4. Диагноз доходит до SQL ────────────────────────────────────────────────
@pytest.mark.parametrize("module_name", ["kit", "app"])
@pytest.mark.parametrize(
("kwargs", "expected"),
[({}, "platform"), ({"ban_kind": "infra"}, "infra")],
)
def test_mark_banned_writes_ban_kind(
module_name: str, kwargs: dict[str, str], expected: str
) -> None:
"""Обе копии runs-модуля кладут ban_kind в UPDATE (default — историческое 'platform')."""
if module_name == "kit":
mod: Any = kit_runs
else:
from app.services import scrape_runs as app_runs
mod = app_runs
db = MagicMock()
mod.mark_banned(db, 1, "err", {"lots_fetched": 0}, **kwargs)
sql, params = db.execute.call_args_list[0][0] # первый UPDATE, не alert-запрос после
assert "ban_kind = :ban_kind" in str(sql)
assert params["ban_kind"] == expected