gendesign/tradein-mvp/backend/tests/test_2686_ban_kind_split.py
bot-backend 1141035899
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
chore(tradein): две ручки ротации, которых не осталось читателей, и врущий комментарий над ними
proxy_rotate_attempts / proxy_rotate_attempt_timeout_s тюнили ретраи changeip-GET.
Сам changeip снят в #2616 шаг 2 (аккаунт mobileproxy закрыт, ссылки нет), и с тех
пор ручки живут пламбингом: Settings -> property адаптера -> поле протокола
ScraperConfig -> и всё. Ни одного потребителя, только четыре теста, которые
заполняют их при сборке конфига.

Комментарий над ними в contracts.py оправдывал их сохранение так:
  «оставлены как budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for»
— но wait_for там берёт СОСЕДНЕЕ поле, avito_proxy_rotate_settle_s
(avito_detail_backfill.py:249). То есть комментарий приписывал этим двум полям
работу третьего и тем самым прикрывал их мёртвость.

Соседние ручки проверены и ОСТАВЛЕНЫ, они действительно читаются:
  * avito/cian/yandex_proxy_max_rotations — pipeline._max_rotations;
  * avito_proxy_rotate_settle_s — asyncio.wait_for в avito_detail_backfill.

Заодно сжат комментарий в config.py: перечисление истории changeip заменено на
то, что нужно знать сейчас — кто читает оставшиеся две ручки и где живая ротация
(ASOCKS_API_TOKEN / proxy_rotation, #2611).

ruff clean, 4974 passed / 37 skipped.
2026-08-29 17:07:04 +03:00

253 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_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,
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