gendesign/tradein-mvp/backend/tests/test_2723_browser_probe.py
bot-backend 08bb9d6549
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
fix(tradein/proxy): проба спрашивает каждую площадку, вердикт пишется на пару «узел × источник» (#2800 B) (#2803)
2026-08-09 18:28:37 +00:00

337 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#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