浏览器自动恢复逻辑
This commit is contained in:
@@ -38,6 +38,8 @@ class BrowserPool:
|
||||
self._contexts_since_start = 0
|
||||
self._browser_started_at: float | None = None
|
||||
self._active_contexts = 0
|
||||
# 浏览器实例「代次」:每次成功 start 自增,用于并发重建时判断当前实例是否已被他人换新
|
||||
self._browser_generation = 0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
@@ -105,6 +107,7 @@ class BrowserPool:
|
||||
self._started = True
|
||||
self._startup_error = None
|
||||
self._contexts_since_start = 0
|
||||
self._browser_generation += 1
|
||||
self._browser_started_at = asyncio.get_running_loop().time()
|
||||
logger.info("浏览器启动完成")
|
||||
except Exception as exc:
|
||||
@@ -159,10 +162,20 @@ class BrowserPool:
|
||||
self._playwright_cm = None
|
||||
self._started = False
|
||||
|
||||
async def _restart_browser(self, reason: str) -> None:
|
||||
"""在浏览器掉线后串行重建 Playwright 运行时。"""
|
||||
async def _restart_browser(self, reason: str, *, observed_generation: int | None = None) -> None:
|
||||
"""在浏览器掉线后串行重建 Playwright 运行时。
|
||||
|
||||
observed_generation 为调用方观察到异常时的浏览器代次:
|
||||
- 传入时:若持锁后代次已变化,说明其他协程已重建,直接返回;否则强制重建
|
||||
(此场景已确认 new_context 因 driver 断链失败,不再依赖 is_connected 探测,
|
||||
规避 connection_lost 事件滞后导致的误判)。
|
||||
- 不传时:沿用 is_connected 探测,连通则跳过(用于获取页面前的预检)。
|
||||
"""
|
||||
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
|
||||
|
||||
logger.warning("检测到浏览器连接不可用,准备重建浏览器实例:reason=%s", reason)
|
||||
@@ -227,14 +240,22 @@ class BrowserPool:
|
||||
raise BrowserUnavailableError(self._startup_error or "Browser recycle failed")
|
||||
|
||||
@staticmethod
|
||||
def _should_retry_context_creation(exc: Exception) -> bool:
|
||||
"""识别 Playwright driver 断链类错误,允许执行一次重建后重试。"""
|
||||
def is_disconnect_error(exc: Exception) -> bool:
|
||||
"""识别 Playwright driver 断链类错误,作为「重建后重试」的统一判据。
|
||||
|
||||
浏览器池内部与上层(CloudflareSessionManager)共用此判据,集中维护标记,
|
||||
避免各处散落的字符串匹配漂移。
|
||||
"""
|
||||
message = str(exc).lower()
|
||||
retry_markers = (
|
||||
"connection closed while reading from the driver",
|
||||
"browser has been closed",
|
||||
"target page, context or browser has been 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)
|
||||
|
||||
@@ -262,35 +283,56 @@ class BrowserPool:
|
||||
except 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 断链失败,重建浏览器后重试一次。
|
||||
|
||||
计入回收阈值统计;不负责并发信号量与关闭,由调用方管理生命周期。
|
||||
启用 stealth 时,playwright 已被 use_async 钩子包装,new_context 会自动
|
||||
应用 stealth(含 HTTP sec-ch-ua),此处无需再手动 apply。
|
||||
factory 必须是「可重复调用、每次从零创建上下文/页面」的协程工厂——因为
|
||||
重建浏览器会令旧上下文一并失效,恢复的唯一正确粒度是重新创建。
|
||||
借助 _browser_generation 守卫,并发场景下只重建一次,后到者复用新实例。
|
||||
"""
|
||||
context = None
|
||||
for attempt in range(2):
|
||||
observed_generation = self._browser_generation
|
||||
try:
|
||||
context = await self._browser.new_context(
|
||||
locale=self._settings.browser_locale,
|
||||
timezone_id=self._settings.browser_timezone_id,
|
||||
user_agent=self._settings.browser_user_agent,
|
||||
viewport=None if not self._settings.browser_headless_effective else {"width": 1440, "height": 900},
|
||||
storage_state=storage_state,
|
||||
)
|
||||
break
|
||||
return await factory()
|
||||
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}")
|
||||
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(含 HTTP sec-ch-ua),此处无需再手动 apply。
|
||||
"""
|
||||
context = await self._browser.new_context(
|
||||
locale=self._settings.browser_locale,
|
||||
timezone_id=self._settings.browser_timezone_id,
|
||||
user_agent=self._settings.browser_user_agent,
|
||||
viewport=None if not self._settings.browser_headless_effective else {"width": 1440, "height": 900},
|
||||
storage_state=storage_state,
|
||||
)
|
||||
# 上下文创建成功,计入回收阈值统计
|
||||
self._contexts_since_start += 1
|
||||
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
|
||||
async def _configure_page(context: Any) -> Any:
|
||||
"""在上下文中创建页面并设置反检测请求头。"""
|
||||
@@ -306,7 +348,16 @@ class BrowserPool:
|
||||
调用方需在不再使用时调用 close_context 释放。
|
||||
"""
|
||||
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
|
||||
async def page_in_context(self, context: Any) -> AsyncIterator[Any]:
|
||||
@@ -396,12 +447,14 @@ class BrowserPool:
|
||||
try:
|
||||
# 创建上下文前检查是否需要主动回收(仅在空闲时执行,不打断进行中的会话)
|
||||
await self._maybe_recycle()
|
||||
context = await self._build_context_obj(storage_state)
|
||||
# 上下文创建成功,标记为在用(回收阈值计数已在 _build_context_obj 内完成)
|
||||
# 上下文与页面创建作为一个整体进行断链重试:context 与 browser 实例绑定,
|
||||
# 重建 browser 后必须连同 context 一起重新创建,二者不可拆开重试。
|
||||
context, page = await self._run_with_reconnect(
|
||||
lambda: self._open_context_and_page(storage_state), "打开页面会话"
|
||||
)
|
||||
# 创建成功后才标记为在用(回收阈值计数已在 _new_context_raw 内完成)
|
||||
self._active_contexts += 1
|
||||
context_counted = True
|
||||
|
||||
page = await self._configure_page(context)
|
||||
yield context, page
|
||||
except Exception:
|
||||
if self._settings.browser_keep_open_on_error_effective and context is not None:
|
||||
|
||||
Reference in New Issue
Block a user