gendesign/tradein-mvp/backend/tests/test_3118_domclick_no_proxy.py
bot-backend ade1a065d6
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (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 / backend-tests (pull_request) Successful in 4m42s
fix(tradein/domclick): исчерпание пула прокси свипа теперь infra-бан, не отказ сбора
NoProxyAvailableError поднимается из BrowserFetcher.__aenter__ (_acquire_lease)
ДО первого HTTP-запроса, когда пул прокси пуст — это НАША инфраструктура, не
блокировка площадкой. У run_avito_full_load/run_cian_full_load/run_yandex_full_load
уже есть выделенный except NoProxyAvailableError -> mark_banned(ban_kind='infra'),
у run_domclick_city_sweep его не было: исключение проваливалось в общий except
Exception внутри SERP-фазы, _scraper_ref оставался пустым, и честный статус ниже
видел "0 лотов + errors>0" -> mark_failed("fetch errors — 0 listings") с
ban_kind=NULL. Прод-факт: run 5023 (27.08) умер за 51 мс, errors_count=1,
ban_kind=NULL — неотличимо от честного отказа сбора площадкой.

Добавлен except NoProxyAvailableError перед generic except Exception (порядок
важен: класс — подкласс RuntimeError). Обработчик зеркалит avito/cian/yandex:
mark_banned + ban_kind_of_exception(exc) (даёт BAN_KIND_INFRA), и сохраняет
унаследованный чекпоинт (skip_buckets) вместо потери его на нашем же отказе.

Тест test_3118_domclick_no_proxy.py проверен на обратимость: без обработчика
падает (mark_failed вместо mark_banned), с обработчиком — проходит.
2026-08-28 00:54:48 +03:00

107 lines
5.3 KiB
Python
Raw Permalink 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.

"""#3118: run_domclick_city_sweep не отличал исчерпание пула прокси от отказа сбора.
`NoProxyAvailableError` поднимается из `BrowserFetcher.__aenter__` (`_acquire_lease`)
ДО первого HTTP-запроса, когда в пуле не осталось свободных узлов — это НАША
инфраструктура, не блокировка площадкой. У run_avito_full_load/run_cian_full_load/
run_yandex_full_load есть выделенный `except NoProxyAvailableError` → mark_banned с
ban_kind_of_exception() (даёт BAN_KIND_INFRA). У домклик-свипа его не было: исключение
проваливалось в общий `except Exception` внутри SERP-фазы, `_scraper_ref` оставался
пустым (scraper не успел создаться), и честный статус ниже видел «0 лотов + errors>0»
→ `mark_failed("fetch errors — 0 listings")` с `ban_kind=NULL`.
Прод-факт: run 5023 (27.08) умер за 51 мс, errors_count=1, ban_kind=NULL — неотличимо
от честного отказа сбора, хотя площадка вообще не была затронута.
На старом коде (до правки) этот тест падает: mark_failed вместо mark_banned,
ban_kind остаётся None. См. `test_2687_domclick_ban_kind.py` — тот же _BanKindRecorder
паттерн, для соседнего исхода (QRATOR-блок → platform).
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.orchestration.runs import BAN_KIND_INFRA
from scraper_kit.proxy_errors import NoProxyAvailableError
PFX = "scraper_kit.orchestration.pipeline"
class _BanKindRecorder:
"""Ловит исход (done/failed/banned) и ban_kind, переданный в mark_banned."""
def __init__(self) -> None:
self.ban_kind: str | None = None
self.marked: list[str] = []
self.errors: 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:
return None
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
self.marked.append("done")
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.marked.append("failed")
self.errors.append(error)
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
self.marked.append("banned")
self.errors.append(error)
self.ban_kind = kw.get("ban_kind")
async def _drive_no_proxy() -> _BanKindRecorder:
"""DomClickScraper.__aenter__ поднимает NoProxyAvailableError ДО первого фетча —
пул опустел. fetch_city не вызывается вовсе (async with не входит в блок)."""
recorder = _BanKindRecorder()
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(side_effect=NoProxyAvailableError("domclick"))
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)])
with (
patch(f"{PFX}.DomClickScraper", return_value=scraper),
patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))),
patch(f"{PFX}.runs", recorder),
):
await run_domclick_city_sweep(
MagicMock(),
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
run_id=5023,
city_id=4,
pages=1,
request_delay_sec=0.0,
)
return recorder
async def test_no_proxy_marks_infra_ban_not_failed() -> None:
"""Пул прокси пуст → banned/infra, НЕ failed. fetch_city не должен звонить площадке."""
recorder = await _drive_no_proxy()
assert recorder.marked == ["banned"], (
f"ожидали ровно один исход 'banned', получили {recorder.marked}"
"исчерпание пула прокси провалилось в общий except и было объявлено "
"отказом сбора (mark_failed), а не инфраструктурным баном"
)
assert recorder.ban_kind == BAN_KIND_INFRA, (
f"ban_kind={recorder.ban_kind!r}, ожидали {BAN_KIND_INFRA!r}"
"отказ пула прокси должен классифицироваться как НАША инфраструктура, "
"не как блокировка площадкой и не как NULL/unknown"
)
assert recorder.errors and "no proxy" in recorder.errors[-1].lower(), (
f"error-сообщение {recorder.errors!r} не упоминает отказ пула — "
"диагностика в scrape_runs.error должна называть причину, а не общий текст"
)