SiteInteractor 的 Chromium 是进程级单例,此前启动后即假定永远活着:全仓唯一的 is_connected() 探活在 scraping 侧,交易侧既不探活也不重启。容器里 Chromium 崩溃 是有真实前提的(/dev/shm 不足、OOM kill、seccomp 挡 sandbox,docker-compose.yml 里已有相关注释),一旦发生,进程还活着但之后每一单都会失败,且 /health 恒返回 ok,restart: unless-stopped 永远不会被触发。 更隐蔽的一条:clear_cart / enter_checkout / pay 等处的 new_page()、context.request 写在 try 之外,掉线抛的 TargetClosedError 不是 AppError,会穿过 runner 的 except AppError 落到主循环那个只记日志的兜底里——任务一次都不上报,网关侧要干等 整个 lease_ttl(默认 300s)才被 sweep 置 stale。clear_cart 是 execute() 的 step 0, 浏览器死时最先撞上的正是它。 分三层处理: - 任务边界自愈。_launch() 从 start() 抽出,_context_options() 统一 context 参数 (重建必须与启动完全一致,指纹漂移就是一次风控事件);_refresh_context_if_stale 改名 _ensure_context_ready(),先探活重建再做原有的 storage_state mtime 检查 (顺序不可换,mtime 重建要用 self._browser)。重建前丢弃 _checkout_pages 里的 残留确认页并记 warning——那些 Page 已随浏览器一起没了。 - 中途掉线不重建,抛 BrowserDeadError(新增,5006)。新增 _new_page() 与 _request() 两个壳收口裸异常;_request() 只在确认浏览器真死了时才改写异常, 站点 5xx 这类正常业务失败原样抛出。submit_order / pay 入口用 _require_live_browser() 直接拒绝:这两步复用 enter_checkout 留存的 Page, 重建救不回服务端订单草稿,而 pay 跑的时候订单已经真的提交了。顺带修掉一个 误诊——浏览器死时 submit_order 原先报「未找到确认按钮」,把「浏览器崩了」 说成「站点改版了」,两者的处置方式完全不同。 - runner 把 BrowserDeadError 转 needs_human 而非 failed,except 分支排在 except AppError 之前(子类,顺序反了就报 failed)。掉线发生在动作中途, 站点侧生效与否无从判断,不能给上游「明确失败」的结论。 /health 暴露 browser 状态,掉线时 degraded + HTTP 503 + code 5006,让 Dockerfile.trading 的 HEALTHCHECK 探到并重启容器。重启不会导致重复下单:网关侧 任务绝不自动重投,租约过期只置 stale 等人工 reclaim(docs/order-gateway.md §5), 重启只是恢复领新任务的能力。启动窗口期 started=False 不算掉线。 README 错误码表补 5005(此前遗漏)与 5006。 新增 15 个用例覆盖探活三态、is_connected() 自身抛错、边界重建/不重建/丢弃残留页/ 重建失败、两个包装壳的分支、submit/pay 拒绝、runner 转 needs_human、/health 503。 真实 Chromium 崩溃无法在离线测试里制造,用例模拟的是 is_connected() 返回 False 这个唯一可观测信号,覆盖的是代码对该信号的反应而非崩溃本身;容器 HEALTHCHECK 真的触发重启这条链路尚未实跑验证。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
53 lines
2.5 KiB
Python
53 lines
2.5 KiB
Python
"""交易服务健康检查路由"""
|
|
from fastapi import APIRouter, Depends, Response
|
|
|
|
from app.shared.api import ApiResponse, get_container
|
|
from app.trading.container import TradingContainer
|
|
from app.trading.models import TradingHealthData
|
|
|
|
router = APIRouter(tags=["health"])
|
|
|
|
|
|
@router.get("/health", response_model=ApiResponse[TradingHealthData])
|
|
async def health(
|
|
response: Response,
|
|
container: TradingContainer = Depends(get_container),
|
|
) -> ApiResponse[TradingHealthData]:
|
|
"""交易服务健康状态
|
|
|
|
auth 给出账号登录态。这里只读缓存、不触发网络探测,避免健康检查被
|
|
上游高频轮询时反复打站点;要实时结果请用 POST /api/auth/status。
|
|
|
|
注意 `logged_in=null` 表示服务启动后还没探测过,不等于未登录。
|
|
|
|
browser 反映 Playwright 浏览器的连接状态。**浏览器已掉线时本接口返回 503**:
|
|
这个进程的所有站点操作都要靠那一个浏览器,它没了以后进程虽然还活着、端口还
|
|
通,却已经什么都干不了(每单都会失败)。返回 503 是为了让容器 HEALTHCHECK
|
|
(Dockerfile.trading)能探到并触发重启——这是掉线自愈的最后一道兜底,前面还有
|
|
SiteInteractor 在任务边界的就地重建。
|
|
|
|
重启是安全的:网关侧的下单任务绝不自动重投(租约过期只置 stale 等人工
|
|
reclaim,见 docs/order-gateway.md §5),所以重启只是让 worker 恢复领**新**
|
|
任务的能力,不会让任何一笔已经在跑的订单被重复下单。
|
|
|
|
尚未 start() 的启动窗口期不算掉线(HEALTHCHECK 有 start-period 兜着),
|
|
只有「起过浏览器且现在连不上」才转 degraded。
|
|
"""
|
|
# site 恒非空(lifespan 必建),留 None 分支只是不为一个健康检查赌这一点
|
|
browser = container.site.browser_status() if container.site is not None else {}
|
|
degraded = bool(browser.get("started")) and not browser.get("alive")
|
|
if degraded:
|
|
response.status_code = 503
|
|
|
|
return ApiResponse[TradingHealthData](
|
|
success=not degraded,
|
|
msg="浏览器已掉线" if degraded else "success",
|
|
data=TradingHealthData(
|
|
status="degraded" if degraded else "ok",
|
|
auth=container.auth_session.status_all(),
|
|
browser=browser,
|
|
),
|
|
# 与 BrowserDeadError 用同一个错误码,上游按同一张错误码表分支
|
|
code=5006 if degraded else 0,
|
|
)
|