diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 7b42fd0e..4403836b 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -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 diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index cbd52c23..72f4ab24 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -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: diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 4d35cd87..5f4c6963 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py b/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py new file mode 100644 index 00000000..6dbaae33 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3384_no_proxy_at_batch_start.py @@ -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()