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>
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""应用异常定义:所有业务异常均继承自 AppError
|
|
|
|
错误码规范:
|
|
- 1xxx: 请求/鉴权错误
|
|
- 2xxx: 抓取资源错误
|
|
- 3xxx: 反爬/上游阻断相关错误
|
|
- 4xxx: 页面解析错误
|
|
- 5xxx: 加购/下单错误(需要账号登录态的写操作)
|
|
- 6xxx: 下单任务编排错误(网关侧的租约与状态机)
|
|
"""
|
|
|
|
|
|
class AppError(Exception):
|
|
"""应用基础异常,携带错误码、HTTP 状态码和是否可重试信息"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
code: str,
|
|
err_code: int,
|
|
retryable: bool = False,
|
|
status_code: int = 400,
|
|
headers: dict[str, str] | None = None,
|
|
):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.code = code
|
|
self.err_code = err_code
|
|
self.retryable = retryable
|
|
self.status_code = status_code
|
|
self.headers = headers
|
|
|
|
|
|
class AuthenticationError(AppError):
|
|
"""鉴权失败(Bearer Token 无效或缺失)"""
|
|
|
|
def __init__(self, message: str = "Invalid credentials"):
|
|
super().__init__(
|
|
message=message,
|
|
code="AUTH_INVALID",
|
|
err_code=1001,
|
|
retryable=False,
|
|
status_code=401,
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
|
|
class InvalidRequestError(AppError):
|
|
"""请求参数不合法(如 URL 非乐天站点、缺少必填标识)"""
|
|
|
|
def __init__(self, message: str = "Invalid request"):
|
|
super().__init__(message=message, code="INVALID_REQUEST", err_code=1003, retryable=False)
|
|
|
|
|
|
class ResourceBusyError(AppError):
|
|
"""抓取资源繁忙(等待并发槽位超时)"""
|
|
|
|
def __init__(self, message: str = "Timed out while waiting for a scrape slot"):
|
|
super().__init__(message=message, code="RESOURCE_BUSY", err_code=2002, retryable=True)
|
|
|
|
|
|
class UpstreamRequestError(AppError):
|
|
"""上游请求失败(网络异常、超时、5xx)"""
|
|
|
|
def __init__(self, message: str = "The upstream request failed"):
|
|
super().__init__(message=message, code="UPSTREAM_ERROR", err_code=3001, retryable=True)
|
|
|
|
|
|
class UpstreamBlockedError(AppError):
|
|
"""上游请求被反爬阻断(Akamai 挑战页 / 403 / 页面缺少渲染数据)"""
|
|
|
|
def __init__(self, message: str = "The upstream request was blocked"):
|
|
super().__init__(message=message, code="UPSTREAM_BLOCKED", err_code=3002, retryable=True)
|
|
|
|
|
|
class ItemNotFoundError(AppError):
|
|
"""商品不存在或已下架(详情页 404)"""
|
|
|
|
def __init__(self, message: str = "Item not found"):
|
|
super().__init__(
|
|
message=message,
|
|
code="ITEM_NOT_FOUND",
|
|
err_code=4004,
|
|
retryable=False,
|
|
status_code=404,
|
|
)
|
|
|
|
|
|
class ScrapeParseError(AppError):
|
|
"""页面解析失败"""
|
|
|
|
def __init__(self, message: str = "Failed to parse the target page"):
|
|
super().__init__(message=message, code="PARSE_ERROR", err_code=4001, retryable=False)
|
|
|
|
|
|
class OffIchibaRedirectError(AppError):
|
|
"""商品页跳转到了市场之外的乐天官方子站
|
|
|
|
楽天ブックス(book → books.rakuten.co.jp)、ビックカメラ
|
|
(biccamera → biccamera.rakuten.co.jp)等官方旗舰店有各自独立的站点与
|
|
页面结构,不返回市场统一模板,本服务的详情解析不适用。
|
|
|
|
单独成一类错误是为了让上游能把这些商品路由到别处,而不是当成被反爬拦截去重试。
|
|
"""
|
|
|
|
def __init__(self, requested_url: str, final_url: str):
|
|
super().__init__(
|
|
message=(
|
|
f"商品页跳转至乐天市场以外的站点,当前不支持解析:"
|
|
f"{requested_url} -> {final_url}"
|
|
),
|
|
code="OFF_ICHIBA_REDIRECT",
|
|
err_code=4002,
|
|
retryable=False,
|
|
)
|
|
self.requested_url = requested_url
|
|
self.final_url = final_url
|
|
|
|
|
|
class NotLoggedInError(AppError):
|
|
"""账号登录态缺失或已失效
|
|
|
|
加购与下单必须带已登录的账号会话。两站登录都要过 reCAPTCHA / 设备验证,
|
|
无法自动恢复,因此这里明确标记 retryable=False,让上游停下来走一次
|
|
`scripts/login.py` 重新人工登录,而不是原地重试。
|
|
"""
|
|
|
|
def __init__(self, site: str, detail: str = ""):
|
|
suffix = f"({detail})" if detail else ""
|
|
super().__init__(
|
|
message=(
|
|
f"{site} 账号未登录或登录态已失效{suffix},"
|
|
f"请运行 scripts/login.py --site {site} 重新登录"
|
|
),
|
|
code="NOT_LOGGED_IN",
|
|
err_code=5001,
|
|
retryable=False,
|
|
status_code=401,
|
|
)
|
|
self.site = site
|
|
self.detail = detail
|
|
|
|
|
|
class CartOperationError(AppError):
|
|
"""加购失败
|
|
|
|
站点对加购请求几乎不返回结构化错误:缺必填选项、SKU 已售罄、商品下架
|
|
都可能返回 200 并把用户导回商品页。因此判定依据是「加购后购物车里有没有
|
|
这件商品」,而不是 HTTP 状态码。
|
|
"""
|
|
|
|
def __init__(self, message: str = "加入购物车失败"):
|
|
super().__init__(message=message, code="CART_FAILED", err_code=5002, retryable=False)
|
|
|
|
|
|
class OrderOperationError(AppError):
|
|
"""下单流程失败(确认页解析不出、金额校验不通过、提交被拒等)"""
|
|
|
|
def __init__(self, message: str = "下单失败"):
|
|
super().__init__(message=message, code="ORDER_FAILED", err_code=5003, retryable=False)
|
|
|
|
|
|
class OrderGuardError(AppError):
|
|
"""下单安全闸门未通过
|
|
|
|
真实付款不可逆,因此把「调用方没有显式确认」「实际金额超出上限」这类拦截
|
|
单独成一类错误,与站点侧失败区分开——前者是本服务主动拒绝,重试无意义,
|
|
需要调用方修改入参后再来。
|
|
"""
|
|
|
|
def __init__(self, message: str):
|
|
super().__init__(message=message, code="ORDER_GUARD", err_code=5004, retryable=False)
|
|
|
|
|
|
class BrowserDeadError(AppError):
|
|
"""Playwright 浏览器已掉线(Chromium 崩溃 / 被 OOM kill / 驱动连接断开)
|
|
|
|
单独成一类而不是复用 OrderOperationError,有两个理由:
|
|
|
|
1. **必须是 AppError**。掉线时 Playwright 抛的是 `TargetClosedError` 这类
|
|
非 AppError 异常,会直接穿过 `runner._execute_with_renewal` 的
|
|
`except AppError`,落到主循环那个只记日志的兜底里——任务一次都不上报,
|
|
网关侧要干等整个 lease_ttl(默认 300s)才被 sweep 置 stale。包成 AppError
|
|
是为了让 worker 能**立刻**回报。
|
|
2. **结论必须是 needs_human 而不是 failed**。浏览器是在动作中途没的,站点侧
|
|
到底生效没有无从判断(尤其 submit_order / pay 之后),按「明确失败」上报
|
|
会误导上游。`runner` 为此单独接这一类,见 _execute_with_renewal。
|
|
|
|
掉线本身能自愈的部分在 `SiteInteractor._ensure_browser_alive()`:**任务边界**
|
|
上探到浏览器没了会就地重建,那条路径不抛本异常。抛到这里的都是重建也救不回来
|
|
的场景(重建失败、掉线发生在一次调用中途、submit/pay 复用的确认页已随浏览器
|
|
一起消失)。
|
|
"""
|
|
|
|
def __init__(self, message: str = "浏览器已掉线"):
|
|
super().__init__(message=message, code="BROWSER_DEAD", err_code=5006, retryable=False)
|
|
|
|
|
|
class CheckoutBlockedError(AppError):
|
|
"""结算流程被站点风控拦截(session upgrade 二次验证 / 3DS / 短信验证等)
|
|
|
|
对应 docs/order-gateway.md §10.1:这类阻断不是本服务的 bug,也不是普通的请求失败,
|
|
是站点主动要求人工验证。实测记录(data/evidence/checkout-research-20260811/NOTES.md):
|
|
session upgrade 密码页多次自动提交均未能确认稳定通过,行为更像是站点对自动化环境
|
|
的针对性降级,而非偶发网络问题——**不适合原地重试**,重试只会累积同账号的失败验证
|
|
次数,增加被风控盯上/锁定的风险。
|
|
|
|
worker 收到这类错误应停在当前进度并上报 needs_human,交人工用有头浏览器接管当前
|
|
登录态完成验证,而不是当成普通失败(retryable/FAILED)处理。
|
|
"""
|
|
|
|
def __init__(self, message: str = "结算流程被站点风控拦截,需人工介入"):
|
|
super().__init__(message=message, code="CHECKOUT_BLOCKED", err_code=5005, retryable=False)
|
|
|
|
|
|
# ---- 下单任务编排(仅网关进程 app.gateway.main 使用)----
|
|
# 下单不可逆,因此任务队列侧的错误一律标记 retryable=False——重复入队/重投
|
|
# 都可能变成重复下单。详见 docs/order-gateway.md §8。
|
|
|
|
|
|
class TaskNotFoundError(AppError):
|
|
"""任务不存在"""
|
|
|
|
def __init__(self, task_id: str):
|
|
super().__init__(
|
|
message=f"任务不存在:{task_id}",
|
|
code="TASK_NOT_FOUND",
|
|
err_code=6001,
|
|
retryable=False,
|
|
status_code=404,
|
|
)
|
|
self.task_id = task_id
|
|
|
|
|
|
class LeaseInvalidError(AppError):
|
|
"""租约无效:不是持有者、已过期或任务已终结
|
|
|
|
worker 在 renew / report / reclaim 时必须校验自己是当前租约的持有者,且任务
|
|
尚未终结。任意一条不满足都报 6002,让 worker 停下来而不是猜测当前状态。
|
|
"""
|
|
|
|
def __init__(self, message: str = "租约无效"):
|
|
super().__init__(
|
|
message=message,
|
|
code="LEASE_INVALID",
|
|
err_code=6002,
|
|
retryable=False,
|
|
status_code=409,
|
|
)
|
|
|
|
|
|
class InvalidTaskStateError(AppError):
|
|
"""任务状态不允许该操作
|
|
|
|
例如对已终结(succeeded/failed/needs_human)的任务再 reclaim。与 6002 的区别:
|
|
6002 是租约本身的问题(不是持有者 / 已过期),6003 是状态机层面的问题
|
|
(当前状态不接受这个动作)。
|
|
"""
|
|
|
|
def __init__(self, message: str = "任务状态不允许该操作"):
|
|
super().__init__(
|
|
message=message,
|
|
code="INVALID_TASK_STATE",
|
|
err_code=6003,
|
|
retryable=False,
|
|
status_code=409,
|
|
)
|
|
|
|
|
|
# ---- 账号只读查询通道(仅网关进程使用,见 docs/order-gateway.md §11)----
|
|
# 与下单任务不同:查询是只读的,**可以安全重投**,因此这两类错误标 retryable=True,
|
|
# 上游重发一张查询单不会有任何副作用。
|
|
|
|
|
|
class QueryNotFoundError(AppError):
|
|
"""查询单不存在(或已过保留期被清理)"""
|
|
|
|
def __init__(self, query_id: str):
|
|
super().__init__(
|
|
message=f"查询单不存在:{query_id}",
|
|
code="QUERY_NOT_FOUND",
|
|
err_code=6005,
|
|
retryable=False,
|
|
status_code=404,
|
|
)
|
|
self.query_id = query_id
|
|
|
|
|
|
class QueryLeaseInvalidError(AppError):
|
|
"""查询单租约无效:不是持有者、已被重投给别人或已终结
|
|
|
|
最常见的触发场景是 worker 执行超时、查询单被 sweep 重投后,原 worker 才姗姗
|
|
来迟地回结果——这时结果必须被拒绝,否则会覆盖掉新一轮的执行结果。
|
|
"""
|
|
|
|
def __init__(self, message: str = "查询单租约无效"):
|
|
super().__init__(
|
|
message=message,
|
|
code="QUERY_LEASE_INVALID",
|
|
err_code=6006,
|
|
retryable=True,
|
|
status_code=409,
|
|
)
|
|
|
|
|
|
class CatalogOrderNotFoundError(AppError):
|
|
"""编目订单不存在:定时下派通道的 account_orders 里没有这笔订单
|
|
|
|
沿用 6005(与查询单同号的「不存在」语义),区别只在对象是编目里的订单而非
|
|
查询单,便于上游按同一张错误码表分支。
|
|
"""
|
|
|
|
def __init__(self, order_number: str):
|
|
super().__init__(
|
|
message=f"编目订单不存在:{order_number}",
|
|
code="CATALOG_ORDER_NOT_FOUND",
|
|
err_code=6005,
|
|
retryable=False,
|
|
status_code=404,
|
|
)
|
|
self.order_number = order_number
|