All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 2m59s
Deploy Trade-In / build-backend (push) Successful in 2m1s
Deploy Trade-In / deploy (push) Successful in 2m14s
255 lines
11 KiB
Python
255 lines
11 KiB
Python
"""#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_UNKNOWN),
|
||
],
|
||
)
|
||
def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None:
|
||
"""Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип.
|
||
|
||
Тип, который НИЧЕГО не доказывает, с #2764 даёт 'unknown': `else PLATFORM` здесь
|
||
был тем же назначенным диагнозом, что и дефолт mark_banned, только уровнем ниже.
|
||
"""
|
||
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_UNKNOWN,
|
||
) -> 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, диагноз был бы
|
||
'unknown' (дефолт с #2764; до него — '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"),
|
||
[({}, "unknown"), ({"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; дефолт — 'unknown' (#2764).
|
||
|
||
Дефолт был '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
|