From 74461e8611de89ebf04324cb1b000ca105115548 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 14 Jun 2026 20:16:12 +0500 Subject: [PATCH] fix(tradein-browser): non-fatal launch + background proxy retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прокси 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 --- tradein-mvp/browser/server.py | 129 ++++++++++++++++++++++- tradein-mvp/browser/test_server_smoke.py | 124 ++++++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/browser/test_server_smoke.py diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 0d2081ed..bd692f82 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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: diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py new file mode 100644 index 00000000..2725bf6c --- /dev/null +++ b/tradein-mvp/browser/test_server_smoke.py @@ -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