fix(tradein): пустой пул ДО первого объявления теряет диагноз в записи прогона (#3384)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 4m55s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped

Lease берётся один раз в BrowserFetcher.__aenter__, поэтому на проде с пустым пулом
NoProxyAvailableError вылетает из самого `async with` — ДО первой карточки и мимо
стоп-механики внутри цикла (no_proxy_stop = True; break), которая и пишет
counters.no_proxy_stop. Общий `except Exception` ловил его и делал mark_failed с
нулевыми counters без ключа: прогон, который к площадке не ходил вообще, в SQL-разборе
простоя по counters.no_proxy_stop (#3288/#3367) не находится.

Дыра одинаковая у всех трёх бэкфиллов (avito её тоже не обрабатывал: __aenter__
вызывается напрямую строкой 427, отказ уходит в тот же общий except). Правка — в трёх
уже существующих обработчиках, которые и так зовут mark_failed: ключ no_proxy_stop=1
при caused_by_no_proxy(exc). Оборачивать `async with` в try/except пришлось бы с
переносом ~200 строк тела под новый отступ в каждом файле, и покрывало бы только
падение на входе; здесь ловится любой путь мимо цикла.

Тест — через настоящий BrowserFetcher: подделан только провайдер прокси (его acquire
поднимает NoProxyAvailableError), отказ рождается там же, где в проде. Проверяется
failed + no_proxy_stop=1 + attempted=0 (у циана listings_processed=0) и ноль POST'ов
в сайдкар.

Closes #3384
This commit is contained in:
bot-backend 2026-09-06 05:48:32 +05:00
parent 33444b7eab
commit caa3b8b056
4 changed files with 197 additions and 2 deletions

View file

@ -42,6 +42,7 @@ from scraper_kit.orchestration import runs as kit_runs
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
from scraper_kit.orchestration.scheduler import compute_next_run_at
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -208,6 +209,15 @@ async def _execute_cian_backfill(
)
except Exception as exc:
logger.exception("scheduler: cian_history_backfill run_id=%d failed", run_id)
if caused_by_no_proxy(exc):
# #3384: пул был пуст ещё ДО первого объявления — lease берётся в
# BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из
# самого `async with` (cian_history_backfill.py:218) мимо стоп-механики
# внутри цикла, которая и ставит no_proxy_stop. Без этого ключа прогон,
# который к площадке не ходил ВООБЩЕ, неотличим от любого другого падения:
# counters нулевые, причина только в тексте, а разбор простоя идёт SQL'ём
# по counters.no_proxy_stop (#3288/#3367). Ключ тот же, что у ветки выше.
counters["no_proxy_stop"] = 1
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -1088,7 +1088,16 @@ async def run_avito_detail_backfill(
run_id,
counters.duration_sec,
)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
current_counters = counters.to_dict()
if _caused_by_empty_pool(exc):
# #3384: пул был пуст ещё ДО первой карточки — lease берётся в
# BrowserFetcher.__aenter__ (строка 427), поэтому NoProxyAvailableError
# вылетает мимо стоп-механики цикла, которая и ставит no_proxy_stop. Без
# ключа такой прогон (attempted=0, к площадке не ходили) неотличим от
# любого другого падения: разбор простоя идёт SQL'ём по
# counters.no_proxy_stop (#3288/#3367), а не грепом текста ошибки.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters)
raise
finally:

View file

@ -642,5 +642,14 @@ async def run_domclick_detail_backfill(
run_id,
counters.duration_sec,
)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
current_counters = counters.to_dict()
if _caused_by_empty_pool(exc):
# #3384: пул был пуст ещё ДО первой карточки — lease берётся в
# BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из
# самого `async with` (строка 403) мимо стоп-механики цикла, которая и
# ставит no_proxy_stop. Без ключа такой прогон (attempted=0, к площадке не
# ходили) неотличим от любого другого падения: разбор простоя идёт SQL'ём
# по counters.no_proxy_stop (#3283/#3367), а не грепом текста ошибки.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters)
raise

View file

@ -0,0 +1,167 @@
"""#3384 — пустой пул ДО первого объявления терял диагноз в записи прогона.
Lease берётся ОДИН раз в `BrowserFetcher.__aenter__` (см. `_acquire_lease`), поэтому
на проде с пустым пулом `NoProxyAvailableError` вылетает из самого `async with`,
ДО первой карточки то есть мимо стоп-механики внутри цикла (`no_proxy_stop = True;
break`), которая и пишет `counters.no_proxy_stop`. Общий `except Exception` ловил его и
писал `mark_failed` со стухшими нулевыми counters без ключа: прогон, который к площадке
не ходил ВООБЩЕ, в SQL-разборе простоя по `counters.no_proxy_stop` (#3288/#3367) не
находится и выглядит как произвольное падение задачи.
Подделка ровно одна провайдер прокси, чей `acquire()` поднимает
`NoProxyAvailableError` (так ведёт себя пул, когда живых узлов не осталось).
`BrowserFetcher` НАСТОЯЩИЙ: отказ рождается там же, где в проде, и проходит весь
рабочий тракт до `mark_failed`. Счётчик POST'ов в сайдкар считается на
`httpx.AsyncClient.post` «к площадке не ходили» проверяется, а не предполагается.
Сеть/БД замоканы; в сеть тест не ходит.
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
sys.modules.setdefault("weasyprint", MagicMock())
import httpx # noqa: E402
import pytest # noqa: E402
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
from app.services import scheduler as sched # noqa: E402
from app.tasks import avito_detail_backfill as adb # noqa: E402
from app.tasks import cian_history_backfill as chb # noqa: E402
from app.tasks import domclick_detail_backfill as dcb # noqa: E402
class _EmptyPoolProvider:
"""Провайдер прокси, у которого не осталось живых узлов."""
def __init__(self, *_a: Any, **_kw: Any) -> None:
pass
def acquire(self, source: str) -> None:
raise NoProxyAvailableError(source)
def release(self, *_a: Any, **_kw: Any) -> None:
pass
def mark_health(self, *_a: Any, **_kw: Any) -> None:
pass
def _mock_db(n_rows: int, url: str) -> MagicMock:
rows = [{"id": i + 1, "source_url": f"{url}{i + 1}"} for i in range(n_rows)]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = rows
db.execute.return_value = sel
return db
def _prod_pool(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прод + включённый пул — единственная конфигурация, где отказ #2616 живой."""
monkeypatch.setattr(chb.settings, "use_proxy_pool_browser", True)
monkeypatch.setattr(chb.settings, "environment", "production")
monkeypatch.setattr(chb.settings, "browser_http_endpoint", "http://browser:9000")
def _mock_session_svc() -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = None
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
@pytest.mark.asyncio
async def test_cian_empty_pool_at_start_marks_failed_with_no_proxy_stop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Циан: пул пуст на старте → failed + no_proxy_stop=1, обработано 0 объявлений."""
_prod_pool(monkeypatch)
db = _mock_db(5, "https://ekb.cian.ru/sale/flat/")
runs = MagicMock()
post = AsyncMock()
with (
patch.object(chb, "RealProxyProvider", _EmptyPoolProvider),
patch.object(sched, "runs_mod", runs),
patch.object(httpx.AsyncClient, "post", post),
pytest.raises(NoProxyAvailableError),
):
await sched._execute_cian_backfill(
db, run_id=3384, params={"batch_size": 5, "do_houses": False}
)
runs.mark_done.assert_not_called()
runs.mark_banned.assert_not_called()
runs.mark_failed.assert_called_once()
counters = runs.mark_failed.call_args.args[3]
assert counters["no_proxy_stop"] == 1, f"counters={counters}: диагноз потерян"
assert counters["listings_processed"] == 0, "к площадке не ходили — 0 объявлений"
post.assert_not_called()
@pytest.mark.asyncio
async def test_domclick_empty_pool_at_start_marks_failed_with_no_proxy_stop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Домклик: тот же контракт — failed + no_proxy_stop=1 + attempted=0."""
_prod_pool(monkeypatch)
db = _mock_db(5, "https://ekaterinburg.domclick.ru/card/sale__flat__")
runs = MagicMock()
post = AsyncMock()
with (
patch.object(dcb, "RealProxyProvider", _EmptyPoolProvider),
patch.object(dcb, "domclick_session_svc", _mock_session_svc()),
patch.object(dcb, "runs_mod", runs),
patch.object(httpx.AsyncClient, "post", post),
pytest.raises(NoProxyAvailableError),
):
await dcb.run_domclick_detail_backfill(
db, run_id=3384, params={"batch_size": 5, "budget_sec": 3600}
)
runs.mark_backfill_finished.assert_not_called()
runs.mark_failed.assert_called_once()
counters = runs.mark_failed.call_args.args[3]
assert counters["no_proxy_stop"] == 1, f"counters={counters}: диагноз потерян"
assert counters["attempted"] == 0, "к площадке не ходили — 0 попыток"
post.assert_not_called()
@pytest.mark.asyncio
async def test_avito_empty_pool_at_start_marks_failed_with_no_proxy_stop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Avito: BrowserFetcher.__aenter__ вызывается напрямую — дыра та же, контракт тот же."""
_prod_pool(monkeypatch)
monkeypatch.setattr(adb.settings, "scraper_fetch_mode", "browser")
monkeypatch.setattr(adb.settings, "avito_detail_backfill_use_curl", False)
db = _mock_db(5, "https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_")
runs = MagicMock()
post = AsyncMock()
with (
patch.object(adb, "RealProxyProvider", _EmptyPoolProvider),
patch.object(adb, "AvitoScraper", MagicMock()),
patch.object(adb, "runs_mod", runs),
patch.object(httpx.AsyncClient, "post", post),
pytest.raises(NoProxyAvailableError),
):
await adb.run_avito_detail_backfill(
db, run_id=3384, params={"batch_size": 5, "budget_sec": 3600}
)
runs.mark_done.assert_not_called()
runs.mark_failed.assert_called_once()
counters = runs.mark_failed.call_args.args[3]
assert counters["no_proxy_stop"] == 1, f"counters={counters}: диагноз потерян"
assert counters["attempted"] == 0, "к площадке не ходили — 0 попыток"
post.assert_not_called()