Files
rakuten-api/app/shared/errors.py
T
q792602257andClaude Opus 5 f6c3976c0a feat(gateway): 定时下派通道——周期性盘点账号订单并回写网关编目
新增 §12 定时下派通道:网关自己定期派 order_list / order_detail 查询,把账号
真实订单沉淀进新表 account_orders(回写网关),上游可直接查 GET /api/account/orders
拿到账号里实际有哪些订单,不必自己记 order_number。

- collector.py: OrderDiscoveryCollector 常驻后台任务(与 sweep 并列)。每隔
  account_discovery_interval_seconds 派 order_list,收割结果后把「编目里没有的
  订单」upsert 进编目,再逐笔派 order_detail 沉淀 delivery_status/order_state。
  状态无痕:不新增编排跟踪表,仅内存 _pending + _detail_dispatched_this_day;
  detail query_id 按天分段(discover-detail-<order>-<日期>),同天幂等防重复派。
  边界:collector 只消费 worker 结果的规范化字段,绝不解析 raw/raw_pages。
- db.py: account_orders 表 + AccountOrderRow + upsert/single/list/stale 访问;
  upsert 以 order_number 为主键,重复采集只刷新,列表重扫不抹详情节点。
- 路由: GET /api/account/orders(列编目)、GET /api/account/orders/{n}(6005)、
  POST /api/account/discovery/trigger(手动立即派一轮 list)。
- config: RAKUTEN_ACCOUNT_DISCOVERY_ENABLED / _INTERVAL_SECONDS / _MAX_PAGES /
  RAKUTEN_ACCOUNT_DETAIL_REFRESH_SECONDS。
- 既有网关测试夹具统一关 discovery(会启动即派扫描污染查询队列语义),
  新表测试在 tests/test_gateway_discovery.py(13 用例,真实 QueryQueue+DB 装配)。

472 tests passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:35:51 +08:00

298 lines
10 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 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