fix(tradein-browser): non-fatal launch + background proxy retry (crash-loop 3141x) #1330

Merged
bot-backend merged 1 commit from fix/tradein-browser-crashloop-resilience into main 2026-06-14 15:18:59 +00:00
2 changed files with 251 additions and 2 deletions

View file

@ -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:

View 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