fix(tradein/domclick): исчерпание пула прокси свипа теперь infra-бан, не отказ сбора
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
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
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), с обработчиком — проходит.
This commit is contained in:
parent
55e13fd92d
commit
ade1a065d6
2 changed files with 132 additions and 0 deletions
107
tradein-mvp/backend/tests/test_3118_domclick_no_proxy.py
Normal file
107
tradein-mvp/backend/tests/test_3118_domclick_no_proxy.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""#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 должна называть причину, а не общий текст"
|
||||||
|
)
|
||||||
|
|
@ -4420,6 +4420,31 @@ async def run_domclick_city_sweep(
|
||||||
_sweep_timeout,
|
_sweep_timeout,
|
||||||
)
|
)
|
||||||
counters.errors_count += 1
|
counters.errors_count += 1
|
||||||
|
except NoProxyAvailableError as exc:
|
||||||
|
# #3118: пул опустел ДО первого HTTP-запроса — исключение поднимается из
|
||||||
|
# BrowserFetcher.__aenter__ (_acquire_lease) внутри async with DomClickScraper(...),
|
||||||
|
# то есть ДО _scraper_ref.append(_scraper). Без этой ветки NoProxyAvailableError
|
||||||
|
# (подкласс Exception) проваливался в generic except Exception ниже: errors_count
|
||||||
|
# растёт, но _scraper_ref остаётся пустым (blocked=0, buckets_completed=0/0) — и
|
||||||
|
# честный статус ниже видел «0 лотов + errors>0» и звал mark_failed("fetch errors —
|
||||||
|
# 0 listings") с ban_kind=NULL. Прод-след (run 5023, 27.08): умер за 51 мс,
|
||||||
|
# errors_count=1, ban_kind=NULL — неотличимо от честного отказа сбора площадкой,
|
||||||
|
# хотя площадка вообще не была затронута. Тот же диагноз и тот же по духу
|
||||||
|
# обработчик, что у run_avito_full_load/run_cian_full_load/run_yandex_full_load
|
||||||
|
# (см. except NoProxyAvailableError там же) — ban_kind_of_exception() относит тип
|
||||||
|
# исключения к BAN_KIND_INFRA («наша инфраструктура», не площадка). done_buckets
|
||||||
|
# сохраняем как унаследованные skip_buckets — этот прогон новых не завершил, но
|
||||||
|
# терять уже собранный чекпоинт (#2687-класс дефекта) resume не должен.
|
||||||
|
logger.error("domclick-sweep run_id=%d: no proxy available — %s", run_id, exc)
|
||||||
|
counters.errors_count += 1
|
||||||
|
runs.mark_banned(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
f"domclick sweep aborted: {exc}",
|
||||||
|
{**counters.to_dict(), "done_buckets": sorted(skip_buckets)},
|
||||||
|
ban_kind=ban_kind_of_exception(exc),
|
||||||
|
)
|
||||||
|
return counters
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
|
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
|
||||||
counters.errors_count += 1
|
counters.errors_count += 1
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue