"""#2723 — проба здоровья прокси ходит тем же трактом, что и работа. Что сторожится (каждый тест падает на коде origin/main): 1. Классификация отказа браузерной пробы: узлу принадлежит ТОЛЬКО отказ прокси (503 «browser unavailable», 500 NS_ERROR_PROXY_*). Лежащий сайдкар и пустая страница — не его вина. Без этого одна упавшая общая зависимость пометила бы непригодными ВСЕ узлы разом — #2686 в третий раз. 2. Тракт пробы: POST /fetch (одна навигация) на robots.txt, с прокси узла в теле. Не /fetch-json (тот сначала грузит ГЛАВНУЮ площадки) и не выдача. 3. Главное: успешная ipify-проба НЕ стирает вердикт браузерного тракта. На коде до фикса узел, мёртвый для браузера, выходил из карантина каждые ≤30 минут (mark_health(ok=True) → consecutive_fails=0 + enabled=true) и снова забирал прогон. 4. Два диагноза разведены в обе стороны: провал браузерной пробы НЕ выключает узел и НЕ трогает consecutive_fails; провал ipify не пишет ничего в browser-поля. 5. Пометка непригодности НЕ выводит узел из пула: acquire() отдаёт его последним, но при отсутствии пригодных всё равно выдаёт (голодание хуже) — пул из 4 узлов. 6. Реанимация: успешная браузерная проба снимает пометку (browser_refit). 7. Такт: браузерная проба идёт реже ipify (BROWSER_PROBE_MINUTES) и только по узлам, прошедшим ipify — иначе на каждый прогон приходился бы запуск camoufox на узел. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any import pytest from scraper_kit.browser_fetcher import classify_browser_probe from app.services import proxy_pool from app.services.proxy_pool import BROWSER_PROBE_MINUTES, BROWSER_UNFIT_THRESHOLD, acquire from tests.services.test_proxy_pool import FakeSession, _proxy # ── 1. классификация отказа ────────────────────────────────────────────────── @pytest.mark.parametrize( ("status", "detail", "expected"), [ # Ровно тот текст, которым сайдкар отвечал на все 90 записанных обрывов сбора. (503, '{"error": "browser unavailable (proxy may be down)"}', "proxy"), (500, '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY ..."}', "proxy"), (500, '{"error": "Error: Page.goto: NS_ERROR_UNKNOWN_PROXY_HOST"}', "proxy"), # Сайдкар не сконфигурирован / лежит / отвечает чем-то ещё — узел ни при чём. (503, '{"error": "no proxy configured — refusing direct connection (prod)"}', "sidecar"), (502, "bad gateway", "sidecar"), (None, "ConnectError: [Errno 111] Connection refused", "sidecar"), # Тракт сработал, но ответ не похож на страницу — вопрос к площадке, не к пулу. (200, "", "page"), ], ) def test_classify_browser_probe(status: int | None, detail: str, expected: str) -> None: assert classify_browser_probe(status, detail) == expected def test_sidecar_error_literals_still_exist() -> None: """Тripwire: классификация опирается на текст отказа сайдкара — сторожим его. Если browser/server.py переименует сообщение, «proxy» перестанет распознаваться и непригодный узел молча останется первосортным. Тест падает СРАЗУ, а не через месяц зелёных проб (ровно тот сценарий, из-за которого задача и появилась). """ server_py = Path(__file__).resolve().parents[2] / "browser" / "server.py" src = server_py.read_text(encoding="utf-8") assert "browser unavailable (proxy may be down)" in src # ── 2. тракт пробы ─────────────────────────────────────────────────────────── async def test_probe_goes_through_sidecar_with_node_proxy(monkeypatch: pytest.MonkeyPatch) -> None: """Проба = POST /fetch на robots.txt с прокси УЗЛА в теле, а не httpx-GET мимо всех.""" seen: dict[str, Any] = {} class _Resp: status_code = 200 text = '{"html": "User-agent: *"}' @staticmethod def json() -> dict[str, str]: return {"html": "User-agent: *"} class _Client: def __init__(self, **kw: Any) -> None: seen["timeout"] = kw.get("timeout") async def __aenter__(self) -> _Client: return self async def __aexit__(self, *_: object) -> None: return None async def post(self, url: str, json: dict[str, Any]) -> _Resp: seen["url"] = url seen["payload"] = json return _Resp() import scraper_kit.browser_fetcher as bf monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) ok, fail_kind, _detail = await bf.probe_proxy_via_browser( "http://tradein-browser:3000", "http://u:p@node:8080", proxy_kind="http" ) assert ok is True assert fail_kind is None # тот же сайдкар и тот же эндпоинт, что у боевого сбора assert seen["url"] == "http://tradein-browser:3000/fetch" # НЕ /fetch-json: он делает goto на главную площадки — это уже нагрузка на неё assert not seen["url"].endswith("/fetch-json") # прокси проверяемого узла уезжает в тело — иначе camoufox пойдёт через env-прокси # и проба снова будет измерять не тот узел assert seen["payload"]["proxy"] == "http://u:p@node:8080" # адрес — robots.txt площадки, не выдача и не карточка assert seen["payload"]["url"].endswith("/robots.txt") assert "avito.ru" in seen["payload"]["url"] # ── 3-4. два диагноза разведены ────────────────────────────────────────────── def test_ipify_success_does_not_erase_browser_verdict() -> None: """ГЛАВНОЕ: успешная ipify-проба не воскрешает узел, мёртвый для браузера. До #2723 браузерный вердикт жил в consecutive_fails, и mark_health(ok=True) обнулял его каждые ≤30 минут вместе с enabled=true. """ db = FakeSession([_proxy(1)]) for _ in range(BROWSER_UNFIT_THRESHOLD): proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") row = db._by_id(1) assert row["browser_unfit_since"] is not None proxy_pool.mark_health(db, 1, True, exit_ip="1.2.3.4", latency_ms=100) row = db._by_id(1) assert row["consecutive_fails"] == 0 # HTTP-диагноз сброшен, как и раньше assert row["browser_unfit_since"] is not None # а браузерный — НЕТ assert row["browser_fail_streak"] >= BROWSER_UNFIT_THRESHOLD def test_browser_failure_does_not_disable_node() -> None: """Обратная сторона: провал браузерного тракта не выключает живой узел.""" db = FakeSession([_proxy(1)]) for _ in range(BROWSER_UNFIT_THRESHOLD + 3): proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") row = db._by_id(1) assert row["enabled"] is True # узел жив для HTTP — из пула не выводим assert row["consecutive_fails"] == 0 # и транспортный счётчик не трогаем assert row["browser_unfit_since"] is not None def test_sidecar_outage_blames_nobody() -> None: """Лежащий сайдкар не должен пометить непригодными все узлы разом (#2686-класс).""" db = FakeSession([_proxy(1), _proxy(2)]) for pid in (1, 2): for _ in range(BROWSER_UNFIT_THRESHOLD + 1): outcome = proxy_pool.mark_browser_health( db, pid, False, fail_kind="sidecar", detail="ConnectError" ) assert outcome == "ignored" for pid in (1, 2): assert db._by_id(pid)["browser_unfit_since"] is None assert db._by_id(pid)["browser_fail_streak"] == 0 def test_unconfirmed_failure_keeps_check_at_stale() -> None: """Первый (неподтверждённый) провал не двигает такт — перепроверка на след. прогоне.""" db = FakeSession([_proxy(1, browser_check_at=None)]) proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") assert db._by_id(1)["browser_fail_streak"] == 1 assert db._by_id(1)["browser_check_at"] is None # такт не сдвинут proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503") assert db._by_id(1)["browser_unfit_since"] is not None assert db._by_id(1)["browser_check_at"] is not None # подтверждён → ждём полный такт # ── 5. пометка не выводит узел из пула ─────────────────────────────────────── def test_unfit_node_is_last_in_queue_but_still_reachable() -> None: old = datetime.now(UTC) - timedelta(hours=5) db = FakeSession( [ # непригодный, но «давно не использованный» → до #2723 выдавался ПЕРВЫМ _proxy(1, last_ok_at=old, browser_unfit_since=datetime.now(UTC)), _proxy(2, last_ok_at=datetime.now(UTC)), ] ) lease = acquire(db, "avito") # type: ignore[arg-type] assert lease is not None assert lease.id == 2 # пригодный вперёд, несмотря на ORDER BY last_ok_at def test_all_unfit_still_yields_a_proxy() -> None: """Все узлы непригодны — система НЕ остаётся без прокси (голодание хуже).""" db = FakeSession( [ _proxy(1, browser_unfit_since=datetime.now(UTC)), _proxy(2, browser_unfit_since=datetime.now(UTC)), ] ) lease = acquire(db, "avito") # type: ignore[arg-type] assert lease is not None # ── 6-7. healthcheck: такт, гейт, реанимация ───────────────────────────────── def _patch_probes( monkeypatch: pytest.MonkeyPatch, *, http_ok: bool = True, browser: tuple[bool, str | None, str] = (True, None, "html_len=100"), calls: list[str] | None = None, ) -> None: async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: return (True, "1.2.3.4", 10, None) if http_ok else (False, None, None, "timeout") async def _fake_browser( endpoint: str, proxy_url: str, **_kw: Any ) -> tuple[bool, str | None, str]: if calls is not None: calls.append(proxy_url) return browser monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) import scraper_kit.browser_fetcher as bf monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) async def test_healthcheck_marks_unfit_when_http_green_browser_red( monkeypatch: pytest.MonkeyPatch, ) -> None: """Исторический случай целиком: ipify зелёная, браузер красный → диагноз ставится.""" calls: list[str] = [] _patch_probes( monkeypatch, http_ok=True, browser=(False, "proxy", "503 browser unavailable (proxy may be down)"), calls=calls, ) db = FakeSession([_proxy(1)]) first = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert first["ok"] == 1 and first["failed"] == 0 # HTTP-проба по-прежнему зелёная assert first["browser_checked"] == 1 assert db._by_id(1)["browser_unfit_since"] is None # один провал ещё не приговор second = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert second["browser_unfit"] == 1 row = db._by_id(1) assert row["browser_unfit_since"] is not None assert row["enabled"] is True and row["consecutive_fails"] == 0 # За такт узел опрашивается по КАЖДОЙ обслуживаемой площадке (#2800), а каждый # транспортный отказ ещё и подтверждается повтором: 4 источника × 2 попытки за # прогон. Узловой вердикт по-прежнему один на прогон — browser_checked == 1 выше. assert len(calls) == 2 * 4 * 2 async def test_healthcheck_browser_probe_respects_slow_tick( monkeypatch: pytest.MonkeyPatch, ) -> None: """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет. Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой по-прежнему один на BROWSER_PROBE_MINUTES. """ calls: list[str] = [] _patch_probes(monkeypatch, calls=calls) db = FakeSession([_proxy(1)]) await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert len(calls) == 4 await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox" db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta( minutes=BROWSER_PROBE_MINUTES + 1 ) await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert len(calls) == 8 async def test_healthcheck_skips_browser_probe_when_http_dead( monkeypatch: pytest.MonkeyPatch, ) -> None: """Узел, не прошедший ipify, мёртв целиком — жечь на него запуск camoufox незачем.""" calls: list[str] = [] _patch_probes(monkeypatch, http_ok=False, calls=calls) db = FakeSession([_proxy(1)]) counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert counters["failed"] == 1 assert counters["browser_checked"] == 0 assert calls == [] async def test_healthcheck_skips_browser_probe_when_pool_not_wired( monkeypatch: pytest.MonkeyPatch, ) -> None: """Флаг выключен → браузер ходит мимо пула, вердикт об узлах пула бессмыслен.""" calls: list[str] = [] _patch_probes(monkeypatch, calls=calls) monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", False) db = FakeSession([_proxy(1)]) counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert counters["browser_checked"] == 0 assert calls == [] async def test_healthcheck_revives_unfit_node(monkeypatch: pytest.MonkeyPatch) -> None: """Путь обратно: успешная браузерная проба снимает пометку непригодности.""" _patch_probes(monkeypatch) db = FakeSession( [ _proxy( 1, browser_unfit_since=datetime.now(UTC) - timedelta(days=1), browser_fail_streak=4, browser_check_at=datetime.now(UTC) - timedelta(minutes=BROWSER_PROBE_MINUTES + 1), ) ] ) counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] assert counters["browser_refit"] == 1 row = db._by_id(1) assert row["browser_unfit_since"] is None assert row["browser_fail_streak"] == 0