浏览器自动恢复逻辑

This commit is contained in:
2026-06-15 10:39:47 +08:00
parent 2bcaab1626
commit d9a157c03d
3 changed files with 266 additions and 29 deletions
+77 -24
View File
@@ -38,6 +38,8 @@ class BrowserPool:
self._contexts_since_start = 0 self._contexts_since_start = 0
self._browser_started_at: float | None = None self._browser_started_at: float | None = None
self._active_contexts = 0 self._active_contexts = 0
# 浏览器实例「代次」:每次成功 start 自增,用于并发重建时判断当前实例是否已被他人换新
self._browser_generation = 0
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@@ -105,6 +107,7 @@ class BrowserPool:
self._started = True self._started = True
self._startup_error = None self._startup_error = None
self._contexts_since_start = 0 self._contexts_since_start = 0
self._browser_generation += 1
self._browser_started_at = asyncio.get_running_loop().time() self._browser_started_at = asyncio.get_running_loop().time()
logger.info("浏览器启动完成") logger.info("浏览器启动完成")
except Exception as exc: except Exception as exc:
@@ -159,10 +162,20 @@ class BrowserPool:
self._playwright_cm = None self._playwright_cm = None
self._started = False self._started = False
async def _restart_browser(self, reason: str) -> None: async def _restart_browser(self, reason: str, *, observed_generation: int | None = None) -> None:
"""在浏览器掉线后串行重建 Playwright 运行时。""" """在浏览器掉线后串行重建 Playwright 运行时。
observed_generation 为调用方观察到异常时的浏览器代次:
- 传入时:若持锁后代次已变化,说明其他协程已重建,直接返回;否则强制重建
(此场景已确认 new_context 因 driver 断链失败,不再依赖 is_connected 探测,
规避 connection_lost 事件滞后导致的误判)。
- 不传时:沿用 is_connected 探测,连通则跳过(用于获取页面前的预检)。
"""
async with self._restart_lock: async with self._restart_lock:
if self._is_browser_connected(): if observed_generation is not None:
if self._browser_generation != observed_generation:
return
elif self._is_browser_connected():
return return
logger.warning("检测到浏览器连接不可用,准备重建浏览器实例:reason=%s", reason) logger.warning("检测到浏览器连接不可用,准备重建浏览器实例:reason=%s", reason)
@@ -227,14 +240,22 @@ class BrowserPool:
raise BrowserUnavailableError(self._startup_error or "Browser recycle failed") raise BrowserUnavailableError(self._startup_error or "Browser recycle failed")
@staticmethod @staticmethod
def _should_retry_context_creation(exc: Exception) -> bool: def is_disconnect_error(exc: Exception) -> bool:
"""识别 Playwright driver 断链类错误,允许执行一次重建后重试。""" """识别 Playwright driver 断链类错误,作为「重建后重试」的统一判据。
浏览器池内部与上层(CloudflareSessionManager)共用此判据,集中维护标记,
避免各处散落的字符串匹配漂移。
"""
message = str(exc).lower() message = str(exc).lower()
retry_markers = ( retry_markers = (
"connection closed while reading from the driver", "connection closed while reading from the driver",
"browser has been closed", "browser has been closed",
"target page, context or browser has been closed", "target page, context or browser has been closed",
"connection closed", "connection closed",
# driver 子进程掉线导致 asyncio 传输管道关闭,写入命令时同步报错
"the handler is closed",
"transport closed",
"unable to perform operation",
) )
return any(marker in message for marker in retry_markers) return any(marker in message for marker in retry_markers)
@@ -262,16 +283,32 @@ class BrowserPool:
except Exception: except Exception:
logger.exception("关闭旧的保留调试上下文失败") logger.exception("关闭旧的保留调试上下文失败")
async def _build_context_obj(self, storage_state: dict[str, Any] | None) -> Any: async def _run_with_reconnect(self, factory: Any, what: str) -> Any:
"""创建浏览器上下文(含断链重试),仅负责创建本身 """执行 factory();若因 driver 断链失败,重建浏览器后重试一次
factory 必须是「可重复调用、每次从零创建上下文/页面」的协程工厂——因为
重建浏览器会令旧上下文一并失效,恢复的唯一正确粒度是重新创建。
借助 _browser_generation 守卫,并发场景下只重建一次,后到者复用新实例。
"""
for attempt in range(2):
observed_generation = self._browser_generation
try:
return await factory()
except Exception as exc:
if attempt == 0 and self.is_disconnect_error(exc):
logger.warning("%s 因浏览器断链失败,重建后重试一次:err=%s", what, exc)
await self._restart_browser(
f"{what} failed: {exc}", observed_generation=observed_generation
)
continue
raise
async def _new_context_raw(self, storage_state: dict[str, Any] | None) -> Any:
"""仅创建浏览器上下文本身并计入回收统计,不含断链重试与生命周期管理。
计入回收阈值统计;不负责并发信号量与关闭,由调用方管理生命周期。
启用 stealth 时,playwright 已被 use_async 钩子包装,new_context 会自动 启用 stealth 时,playwright 已被 use_async 钩子包装,new_context 会自动
应用 stealth(含 HTTP sec-ch-ua),此处无需再手动 apply。 应用 stealth(含 HTTP sec-ch-ua),此处无需再手动 apply。
""" """
context = None
for attempt in range(2):
try:
context = await self._browser.new_context( context = await self._browser.new_context(
locale=self._settings.browser_locale, locale=self._settings.browser_locale,
timezone_id=self._settings.browser_timezone_id, timezone_id=self._settings.browser_timezone_id,
@@ -279,18 +316,23 @@ class BrowserPool:
viewport=None if not self._settings.browser_headless_effective else {"width": 1440, "height": 900}, viewport=None if not self._settings.browser_headless_effective else {"width": 1440, "height": 900},
storage_state=storage_state, storage_state=storage_state,
) )
break
except Exception as exc:
if attempt == 0 and self._should_retry_context_creation(exc):
logger.warning("创建浏览器上下文失败,尝试重建浏览器后重试一次:err=%s", exc)
await self._restart_browser(f"new_context failed: {exc}")
continue
raise
# 上下文创建成功,计入回收阈值统计 # 上下文创建成功,计入回收阈值统计
self._contexts_since_start += 1 self._contexts_since_start += 1
return context return context
async def _open_context_and_page(self, storage_state: dict[str, Any] | None) -> tuple[Any, Any]:
"""创建上下文并在其内开页面,作为「断链重试」的最小恢复单元。
页面创建失败时关闭半开上下文,避免重建/重试时泄漏。
"""
context = await self._new_context_raw(storage_state)
try:
page = await self._configure_page(context)
except Exception:
await self.close_context(context)
raise
return context, page
@staticmethod @staticmethod
async def _configure_page(context: Any) -> Any: async def _configure_page(context: Any) -> Any:
"""在上下文中创建页面并设置反检测请求头。""" """在上下文中创建页面并设置反检测请求头。"""
@@ -306,7 +348,16 @@ class BrowserPool:
调用方需在不再使用时调用 close_context 释放。 调用方需在不再使用时调用 close_context 释放。
""" """
await self._ensure_browser_ready() await self._ensure_browser_ready()
return await self._build_context_obj(storage_state) return await self._run_with_reconnect(
lambda: self._new_context_raw(storage_state), "创建长存上下文"
)
async def ensure_ready(self) -> None:
"""对外暴露的浏览器就绪保证:连接断开时自动重建。
供上层在捕获断链异常后、重试前主动调用,将重建判定收敛到浏览器池内。
"""
await self._ensure_browser_ready()
@asynccontextmanager @asynccontextmanager
async def page_in_context(self, context: Any) -> AsyncIterator[Any]: async def page_in_context(self, context: Any) -> AsyncIterator[Any]:
@@ -396,12 +447,14 @@ class BrowserPool:
try: try:
# 创建上下文前检查是否需要主动回收(仅在空闲时执行,不打断进行中的会话) # 创建上下文前检查是否需要主动回收(仅在空闲时执行,不打断进行中的会话)
await self._maybe_recycle() await self._maybe_recycle()
context = await self._build_context_obj(storage_state) # 上下文与页面创建作为一个整体进行断链重试:context 与 browser 实例绑定,
# 上下文创建成功,标记为在用(回收阈值计数已在 _build_context_obj 内完成) # 重建 browser 后必须连同 context 一起重新创建,二者不可拆开重试。
context, page = await self._run_with_reconnect(
lambda: self._open_context_and_page(storage_state), "打开页面会话"
)
# 创建成功后才标记为在用(回收阈值计数已在 _new_context_raw 内完成)
self._active_contexts += 1 self._active_contexts += 1
context_counted = True context_counted = True
page = await self._configure_page(context)
yield context, page yield context, page
except Exception: except Exception:
if self._settings.browser_keep_open_on_error_effective and context is not None: if self._settings.browser_keep_open_on_error_effective and context is not None:
+24 -2
View File
@@ -96,7 +96,10 @@ class CloudflareSessionManager:
return self._to_verified_session(existing) return self._to_verified_session(existing)
logger.debug("创建新会话:session_name=%s force_refresh=%s", session_name, force_refresh) logger.debug("创建新会话:session_name=%s force_refresh=%s", session_name, force_refresh)
session = await self._create_verified_session(session_name=session_name, target_url=target_url) session = await self._build_with_browser_retry(
f"创建验证会话({session_name})",
lambda: self._create_verified_session(session_name=session_name, target_url=target_url),
)
return session return session
async def fetch_html(self, target_url: str) -> tuple[str, VerifiedSession]: async def fetch_html(self, target_url: str) -> tuple[str, VerifiedSession]:
@@ -135,9 +138,28 @@ class CloudflareSessionManager:
return html, self._warm_to_session(warm) return html, self._warm_to_session(warm)
logger.info("创建保活上下文:session_name=%s", session_name) logger.info("创建保活上下文:session_name=%s", session_name)
warm, html = await self._build_warm_context(session_name, target_url) warm, html = await self._build_with_browser_retry(
f"创建保活上下文({session_name})",
lambda: self._build_warm_context(session_name, target_url),
)
return html, self._warm_to_session(warm) return html, self._warm_to_session(warm)
async def _build_with_browser_retry(self, what: str, factory: Any) -> Any:
"""执行 factory();若因浏览器断链失败,重建浏览器后整段重试一次。
factory 每次都从零创建上下文与页面(_build_warm_context / _create_verified_session
均如此),故断链重建后重试是安全的。此层覆盖 new_page、goto、content 等
「上下文创建之后」才发生的断链——这些阶段浏览器池内逐操作的重试无法触及。
"""
try:
return await factory()
except Exception as exc:
if not BrowserPool.is_disconnect_error(exc):
raise
logger.warning("%s 因浏览器断链失败,重建后重试一次:err=%s", what, exc)
await self._browser_pool.ensure_ready()
return await factory()
async def _reuse_warm(self, session_name: str, target_url: str) -> tuple[str, WarmContext] | None: async def _reuse_warm(self, session_name: str, target_url: str) -> tuple[str, WarmContext] | None:
"""尝试复用已有保活上下文。 """尝试复用已有保活上下文。
+162
View File
@@ -0,0 +1,162 @@
"""浏览器池 driver 断链自动恢复逻辑的单元测试(不启动真实 Chromium)。
覆盖本次修复的核心:
- is_disconnect_error 对 WriteUnixTransport "the handler is closed" 等断链错误的识别
- _run_with_reconnect 在断链后重建一次并重试成功
- _browser_generation 守卫:并发下他人已重建时不重复重建
"""
from __future__ import annotations
import pytest
from app.core.config import Settings
from app.services.browser_pool import BrowserPool
class _FakeBrowser:
def __init__(self, connected: bool = True):
self._connected = connected
def is_connected(self) -> bool:
return self._connected
def _make_pool(**overrides) -> BrowserPool:
settings = Settings(**overrides)
pool = BrowserPool(settings)
pool._browser = _FakeBrowser()
pool._started = True
pool._browser_generation = 1
return pool
# 复现线上崩溃日志里的真实报错文本
_HANDLER_CLOSED = (
"Stealth.hooked_new_context_async: unable to perform operation on "
"<WriteUnixTransport closed=True reading=False 0x7f9a27185a40>; the handler is closed"
)
def test_is_disconnect_error_recognizes_closed_transport():
assert BrowserPool.is_disconnect_error(RuntimeError(_HANDLER_CLOSED)) is True
assert BrowserPool.is_disconnect_error(Exception("connection closed")) is True
# 业务异常不应被误判为断链
assert BrowserPool.is_disconnect_error(ValueError("invalid selector")) is False
@pytest.mark.asyncio
async def test_run_with_reconnect_rebuilds_then_retries(monkeypatch):
"""首次因断链失败 → 重建一次 → 第二次成功。"""
pool = _make_pool()
rebuilt: list[str] = []
async def fake_restart(reason, *, observed_generation=None):
rebuilt.append(reason)
pool._browser_generation += 1 # 模拟 start() 自增代次
monkeypatch.setattr(pool, "_restart_browser", fake_restart)
attempts = {"n": 0}
async def factory():
attempts["n"] += 1
if attempts["n"] == 1:
raise RuntimeError(_HANDLER_CLOSED)
return "ok"
result = await pool._run_with_reconnect(factory, "测试操作")
assert result == "ok"
assert attempts["n"] == 2
assert len(rebuilt) == 1
@pytest.mark.asyncio
async def test_run_with_reconnect_gives_up_after_one_retry(monkeypatch):
"""连续两次断链:只重试一次,第二次仍失败则抛出。"""
pool = _make_pool()
async def fake_restart(reason, *, observed_generation=None):
pool._browser_generation += 1
monkeypatch.setattr(pool, "_restart_browser", fake_restart)
attempts = {"n": 0}
async def factory():
attempts["n"] += 1
raise RuntimeError(_HANDLER_CLOSED)
with pytest.raises(RuntimeError):
await pool._run_with_reconnect(factory, "测试操作")
assert attempts["n"] == 2 # 初次 + 一次重试
@pytest.mark.asyncio
async def test_run_with_reconnect_passes_through_non_disconnect(monkeypatch):
"""非断链异常不触发重建,直接抛出。"""
pool = _make_pool()
rebuilt: list[str] = []
async def fake_restart(reason, *, observed_generation=None):
rebuilt.append(reason)
monkeypatch.setattr(pool, "_restart_browser", fake_restart)
async def factory():
raise ValueError("业务错误")
with pytest.raises(ValueError):
await pool._run_with_reconnect(factory, "测试操作")
assert rebuilt == []
@pytest.mark.asyncio
async def test_restart_browser_skips_when_generation_changed():
"""持锁后代次已变化(他人已重建)→ 直接返回,不重复重建。"""
pool = _make_pool()
closed: list[str] = []
async def fake_close():
closed.append("close")
pool.close = fake_close # type: ignore[method-assign]
# 调用方观察到的是旧代次,而当前代次已被他人推进
await pool._restart_browser("stale", observed_generation=pool._browser_generation - 1)
assert closed == [] # 未触发关闭/重建
@pytest.mark.asyncio
async def test_open_context_and_page_closes_context_on_page_failure(monkeypatch):
"""页面创建失败时关闭半开上下文,避免泄漏。"""
pool = _make_pool()
class _Ctx:
def __init__(self):
self.closed = False
async def new_page(self):
raise RuntimeError("new_page boom")
ctx = _Ctx()
async def fake_new_context_raw(storage_state):
return ctx
closed: list[object] = []
async def fake_close_context(c):
closed.append(c)
monkeypatch.setattr(pool, "_new_context_raw", fake_new_context_raw)
monkeypatch.setattr(pool, "close_context", fake_close_context)
with pytest.raises(RuntimeError, match="new_page boom"):
await pool._open_context_and_page(None)
assert closed == [ctx]