fix(tradein-browser): non-fatal launch + background proxy retry
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / changes (pull_request) Successful in 6s
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
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / changes (pull_request) Successful in 6s
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
Прокси mproxy.site упал -> camoufox eager public_ip(proxy) для geoip бросал InvalidProxy из app.startup -> exit(1) -> docker restart-loop (RestartCount 3141). Теперь фейл launch'а не валит startup: контейнер остаётся Up, фоновая задача ретраит launch с backoff (30-60с) пока прокси не вернётся. /fetch и /login отдают 503 при недоступном браузере (caller ловит как failed fetch, не краш). proxy+geoip сохранены — анти-бан fingerprint на месте, никакого direct-connection fallback. Креды прокси не логируются (только exc_type, без str(exc) с user:pass@host). Refs #905
This commit is contained in:
parent
72e0b050f9
commit
74461e8611
2 changed files with 251 additions and 2 deletions
|
|
@ -92,6 +92,15 @@ _browser_cm: object | None = None # AsyncCamoufox context manager (для
|
|||
_page_counter: int = 0 # страниц с момента последнего (пере)запуска
|
||||
_fetch_lock: asyncio.Lock | None = None # сериализация fetch-запросов
|
||||
|
||||
# Resilience (#crash-storm): если прокси (mproxy.site) лежит, camoufox делает
|
||||
# eager public_ip(proxy) для geoip-fingerprint и бросает InvalidProxy. Раньше это
|
||||
# валило app.startup → exit(1) → docker restart → ∞ (RestartCount 3000+). Теперь
|
||||
# фейл launch'а НЕ фатален: контейнер остаётся Up, а фоновая задача retry'ит
|
||||
# launch с backoff пока прокси не вернётся. proxy+geoip СОХРАНЕНЫ (анти-бан).
|
||||
_BROWSER_RETRY_MIN_S: float = 30.0 # стартовый интервал retry launch'а
|
||||
_BROWSER_RETRY_MAX_S: float = 60.0 # потолок backoff'а
|
||||
_browser_retry_task: asyncio.Task[None] | None = None # handle фоновой retry-задачи
|
||||
|
||||
|
||||
async def _launch_browser() -> None:
|
||||
"""Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
|
||||
|
|
@ -147,16 +156,105 @@ async def _relaunch_browser() -> None:
|
|||
await _launch_browser()
|
||||
|
||||
|
||||
async def _try_launch_browser() -> bool:
|
||||
"""Одна попытка launch'а с санитизированным логом фейла.
|
||||
|
||||
НЕ пробрасывает исключение наружу — возвращает True/False. Критично для
|
||||
resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить
|
||||
startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит
|
||||
``user:pass@host`` в тексте своего исключения, поэтому мы логируем только
|
||||
тип + санитизированное сообщение, без ``str(exc)``.
|
||||
|
||||
При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние
|
||||
через _close_browser, чтобы следующий retry стартовал с чистого листа.
|
||||
"""
|
||||
try:
|
||||
await _launch_browser()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"tradein-browser: browser launch failed: proxy unreachable "
|
||||
"(exc_type=%s) — контейнер остаётся Up, retry в фоне",
|
||||
type(exc).__name__,
|
||||
)
|
||||
# Подчистить возможный частичный CM перед следующей попыткой.
|
||||
await _close_browser()
|
||||
return False
|
||||
|
||||
|
||||
async def _browser_retry_loop() -> None:
|
||||
"""Фоновый retry launch'а браузера с backoff, пока прокси не вернётся.
|
||||
|
||||
Идемпотентна: если браузер уже поднят (_browser не None) — сразу выходит.
|
||||
Иначе ретраит _try_launch_browser с возрастающим интервалом
|
||||
(_BROWSER_RETRY_MIN_S → _BROWSER_RETRY_MAX_S). Завершается после первого
|
||||
успешного launch'а. Отменяется в _on_cleanup (CancelledError пробрасывается).
|
||||
"""
|
||||
if _browser is not None:
|
||||
return
|
||||
|
||||
delay = _BROWSER_RETRY_MIN_S
|
||||
while _browser is None:
|
||||
await asyncio.sleep(delay)
|
||||
# Лок сериализует launch с in-flight /fetch lazy-launch и recycle —
|
||||
# иначе два __aenter__ на один глобал _browser затрут друг друга.
|
||||
assert _fetch_lock is not None, "_fetch_lock not initialised"
|
||||
async with _fetch_lock:
|
||||
if _browser is not None:
|
||||
return # handler уже поднял браузер — задача отработала
|
||||
logger.info("tradein-browser: фоновый retry launch'а браузера")
|
||||
launched = await _try_launch_browser()
|
||||
if launched:
|
||||
logger.info("tradein-browser: браузер поднят после retry, задача завершена")
|
||||
return
|
||||
delay = min(delay * 1.5, _BROWSER_RETRY_MAX_S)
|
||||
|
||||
|
||||
def _start_retry_task(app: web.Application) -> None:
|
||||
"""Запускает (если ещё нет) фоновую retry-задачу и кладёт handle в app."""
|
||||
global _browser_retry_task
|
||||
|
||||
existing = _browser_retry_task
|
||||
if existing is not None and not existing.done():
|
||||
return # задача уже бежит — не плодим дубли
|
||||
|
||||
task = asyncio.create_task(_browser_retry_loop())
|
||||
_browser_retry_task = task
|
||||
app["browser_retry_task"] = task
|
||||
|
||||
|
||||
# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _on_startup(app: web.Application) -> None:
|
||||
global _fetch_lock
|
||||
_fetch_lock = asyncio.Lock()
|
||||
await _launch_browser()
|
||||
|
||||
# Launch НЕ фатален: если прокси лежит, camoufox бросит InvalidProxy. Раньше
|
||||
# это валило startup → exit(1) → restart-loop. Теперь ловим, остаёмся Up и
|
||||
# ретраим в фоне. proxy+geoip сохранены — никакого direct-fetch fallback.
|
||||
if not await _try_launch_browser():
|
||||
logger.warning(
|
||||
"tradein-browser: старт без браузера (launch failed) — запуск фонового retry"
|
||||
)
|
||||
_start_retry_task(app)
|
||||
|
||||
|
||||
async def _on_cleanup(app: web.Application) -> None:
|
||||
global _browser_retry_task
|
||||
|
||||
task = _browser_retry_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"tradein-browser: ошибка при отмене retry-задачи: %s", type(exc).__name__
|
||||
)
|
||||
_browser_retry_task = None
|
||||
await _close_browser()
|
||||
|
||||
|
||||
|
|
@ -164,7 +262,12 @@ async def _on_cleanup(app: web.Application) -> None:
|
|||
|
||||
|
||||
async def health_handler(request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
# Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер,
|
||||
# хотя процесс жив и ретраит браузер. browser_ready — для observability:
|
||||
# видно лежит ли прокси, не теряя контейнер. Текущий prod compose НЕ имеет
|
||||
# healthcheck на browser (depends_on: condition: service_started), так что
|
||||
# 200-без-браузера никого не рестартит.
|
||||
return web.json_response({"status": "ok", "browser_ready": _browser is not None})
|
||||
|
||||
|
||||
async def fetch_handler(request: web.Request) -> web.Response:
|
||||
|
|
@ -187,6 +290,18 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
assert _fetch_lock is not None, "_fetch_lock not initialised"
|
||||
|
||||
async with _fetch_lock:
|
||||
# Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка
|
||||
# под локом — вдруг прокси уже вернулся. НИКОГДА не фетчим без прокси/
|
||||
# браузера: если всё ещё None → 503 (caller словит как HTTPStatusError
|
||||
# и обработает как failed fetch, а не как crash).
|
||||
if _browser is None:
|
||||
await _try_launch_browser()
|
||||
if _browser is None:
|
||||
logger.warning("tradein-browser: /fetch 503 — браузер недоступен (proxy may be down)")
|
||||
return web.json_response(
|
||||
{"error": "browser unavailable (proxy may be down)"}, status=503
|
||||
)
|
||||
|
||||
try:
|
||||
html = await _do_fetch(url)
|
||||
except Exception as exc:
|
||||
|
|
@ -290,6 +405,16 @@ async def login_handler(request: web.Request) -> web.Response:
|
|||
assert _fetch_lock is not None, "_fetch_lock not initialised"
|
||||
|
||||
async with _fetch_lock:
|
||||
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
||||
# Lazy-попытка под локом, иначе 503 — без прокси не логинимся.
|
||||
if _browser is None:
|
||||
await _try_launch_browser()
|
||||
if _browser is None:
|
||||
logger.warning("tradein-browser: /login 503 — браузер недоступен (proxy may be down)")
|
||||
return web.json_response(
|
||||
{"error": "browser unavailable (proxy may be down)"}, status=503
|
||||
)
|
||||
|
||||
try:
|
||||
cookies = await _do_login(body)
|
||||
except LoginError as exc:
|
||||
|
|
|
|||
124
tradein-mvp/browser/test_server_smoke.py
Normal file
124
tradein-mvp/browser/test_server_smoke.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""test_server_smoke.py — смоук для resilience-логики tradein-browser.
|
||||
|
||||
Полный браузер (camoufox subprocess) юнит-тестить непрактично, поэтому здесь
|
||||
только смоук: (1) модуль импортируется; (2) /fetch и /login отдают 503 когда
|
||||
браузер не поднят (прокси лежит) — критичный resilience-инвариант, защищающий
|
||||
от crash-storm. camoufox в окружении теста НЕ нужен: launch замокан no-op'ом,
|
||||
имитируя «прокси всё ещё недоступен».
|
||||
|
||||
Запуск (из tradein-mvp/browser/)::
|
||||
|
||||
python -m pytest test_server_smoke.py -q
|
||||
|
||||
aiohttp.test_utils.make_mocked_request не поднимает реальный сервер/сокет —
|
||||
хендлер вызывается напрямую, без event-loop-сервера.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
server = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(server)
|
||||
|
||||
|
||||
def _json_body(response: Any) -> dict[str, Any]:
|
||||
"""Достаёт JSON-тело из aiohttp Response (body — bytes после json_response)."""
|
||||
return json.loads(response.body.decode())
|
||||
|
||||
|
||||
def test_module_imports() -> None:
|
||||
"""Смоук: модуль импортируется и экспонирует ключевые хендлеры."""
|
||||
assert callable(server.fetch_handler)
|
||||
assert callable(server.login_handler)
|
||||
assert callable(server.health_handler)
|
||||
|
||||
|
||||
def test_health_reports_browser_not_ready(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""/health всегда 200, browser_ready=False когда браузер не поднят."""
|
||||
monkeypatch.setattr(server, "_browser", None)
|
||||
request = make_mocked_request("GET", "/health")
|
||||
response = asyncio.run(server.health_handler(request))
|
||||
assert response.status == 200
|
||||
body = _json_body(response)
|
||||
assert body["status"] == "ok"
|
||||
assert body["browser_ready"] is False
|
||||
|
||||
|
||||
def test_fetch_returns_503_when_browser_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""/fetch отдаёт 503 (не 500/краш) когда браузер не поднят и launch не удался.
|
||||
|
||||
Имитирует «прокси лежит»: _browser=None + _try_launch_browser no-op,
|
||||
оставляющий None. Никакого реального camoufox-launch'а / direct-fetch.
|
||||
"""
|
||||
monkeypatch.setattr(server, "_browser", None)
|
||||
monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock())
|
||||
|
||||
async def _no_op_launch() -> bool:
|
||||
return False # прокси всё ещё недоступен → браузер остаётся None
|
||||
|
||||
monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch)
|
||||
|
||||
request = make_mocked_request(
|
||||
"POST", "/fetch", payload=b'{"url": "https://example.com"}'
|
||||
)
|
||||
|
||||
async def _run() -> Any:
|
||||
# make_mocked_request не парсит тело — подменяем .json() на нужный payload.
|
||||
request.json = lambda: _coro({"url": "https://example.com"}) # type: ignore[method-assign]
|
||||
return await server.fetch_handler(request)
|
||||
|
||||
response = asyncio.run(_run())
|
||||
assert response.status == 503
|
||||
body = _json_body(response)
|
||||
assert "browser unavailable" in body["error"]
|
||||
|
||||
|
||||
def test_login_returns_503_when_browser_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""/login тоже отдаёт 503 когда браузер не поднят (без credential-логирования)."""
|
||||
monkeypatch.setattr(server, "_browser", None)
|
||||
monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock())
|
||||
|
||||
async def _no_op_launch() -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch)
|
||||
|
||||
login_body = {
|
||||
"url": "https://cian.example/login",
|
||||
"email": "u@example.com",
|
||||
"password": "secret",
|
||||
"email_selector": "#email",
|
||||
"password_selector": "#password",
|
||||
"submit_selector": "#submit",
|
||||
}
|
||||
request = make_mocked_request("POST", "/login")
|
||||
|
||||
async def _run() -> Any:
|
||||
request.json = lambda: _coro(login_body) # type: ignore[method-assign]
|
||||
return await server.login_handler(request)
|
||||
|
||||
response = asyncio.run(_run())
|
||||
assert response.status == 503
|
||||
body = _json_body(response)
|
||||
assert "browser unavailable" in body["error"]
|
||||
|
||||
|
||||
async def _coro(value: Any) -> Any:
|
||||
"""Хелпер: оборачивает значение в awaitable для подмены request.json()."""
|
||||
return value
|
||||
Loading…
Add table
Reference in a new issue