All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 2m0s
337 lines
17 KiB
Python
337 lines
17 KiB
Python
"""#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": "<html>User-agent: *</html>"}'
|
||
|
||
@staticmethod
|
||
def json() -> dict[str, str]:
|
||
return {"html": "<html>User-agent: *</html>"}
|
||
|
||
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
|