一个模块里同时存在三种错误传递方式:底层 SDK 抛异常,中间层把异常转成 None,上层用返回码判断。结果是一条"订单不存在"的错误,在日志里表现为三种不同记录,而调用方在三种之间各有不同的处理——其中两种漏掉了。
一致性比"选哪种方式"更重要,但选择本身也不是无关的:异常适合"调用方无法继续"的情况,返回码适合"调用方需要分支"的情况,结果对象适合"错误是正常输出之一"的情况。
1. 三种方式的代价
| 方式 | 优点 | 代价 |
|---|---|---|
| 异常 | 无法被忽略 | 控制流隐式,签名看不出 |
| 返回码 | 显式、易测 | 容易被忽略(忘记判断) |
| 结果对象 | 可携带上下文 | 需要类型与解包约定 |
第二项的"容易被忽略"是它最大的问题:忘写 if err: 不会有任何报错,代码照常运行,只是在错误路径上做错事。
三种方式还有一个维度是"跨边界":在进程内传递用异常最省,跨进程(API、消息)只能用返回码或结构化的错误体。所以一个系统里通常需要两种:内部异常 + 边界处的显式错误结构。
第一项正相反:忘处理异常会立刻炸,这让它成为"绝对不能漏"场景的默认选择。
2. Python 的异常边界在哪
| 边界 | 处理原则 |
|---|---|
| 外部输入(HTTP、文件) | 转为领域异常 |
| 第三方库 | 在适配层捕获并转换 |
| 业务规则 | 抛领域异常 |
| 顶层(handler) | 统一转换为响应 |
适配层之外还有一层要处理:重试。可重试的错误(超时、连接失败)与不可重试的错误(参数非法、余额不足)必须分开,否则重试一个永久错误只会放大失败。
核心规则是**"异常只在适配层转换一次"**:让第三方库的异常穿过业务代码,等于把外部依赖的类型泄漏到内部——升级库版本时异常类型一改,所有 except 都失效。
// 适配层:把外部异常转成领域异常,之后内部只见领域异常
class PaymentError(Exception): ...
def charge(order_id: str, amount: Decimal) -> None:
try:
gateway.charge(order_id, amount)
except GatewayTimeout as e: # 第三方类型只在这里出现
raise PaymentError("gateway_timeout") from e
except GatewayRejected as e:
raise PaymentError("rejected") from e
raise ... from e 不只是语法糖:它保留了原始堆栈,日志里能看到"业务异常由哪个底层异常引起"。
3. 返回码的真正问题
| 问题 | 表现 |
|---|---|
| 可被忽略 | 忘记判断(无提示) |
| 语义靠约定 | 0/1/-1 各团队不同 |
| 丢失上下文 | 只知失败不知为何 |
| 与真值混用 | None 既是"没查到"又是"出错" |
第三项(语义靠约定)在实际项目里会造成最烦人的问题:同一个 1 在不同模块里表示"成功"和"一般错误",于是跨模块调用时判断逻辑必须逐处查代码。
第四项是 Python 里最常见的错:函数返回 None 表示"没有结果",同时用它表示"出错",调用方无法区分。
// 反例:None 承载两种含义
def find_user(uid):
try: return repo.get(uid)
except DBError: return None # 与"用户不存在"无法区分
// 正例:结果对象区分三态
@dataclass
class Result:
ok: bool
value: User | None = None
error: str | None = None
4. 结果对象的适用场景
| 场景 | 为什么适合 |
|---|---|
| 批量处理(部分失败) | 需要逐项结果 |
| 校验(多条错误) | 需要聚合 |
| 外部调用(可重试) | 需要错误分类 |
| 业务查询(无结果很正常) | 三态清晰 |
结果对象的代价是"多一层解包":每个调用点都要写 if r.ok,这与返回码的"容易被忽略"是同一个问题。缓解办法是让"忽略"变得不自然——例如不给结果对象提供隐式转真值的路径。
第一项最典型:批量导入 1000 行,其中 3 行失败——异常只能表达"整体失败",而结果对象能表达"每行各自的结论"。
// 批量结果:逐项结论,而不是一个异常
@dataclass
class ItemResult:
line: int
ok: bool
error: str | None = None
def import_rows(rows) -> list[ItemResult]:
out = []
for i, r in enumerate(rows, 1):
try:
validate(r); save(r)
out.append(ItemResult(i, True))
except ValidationError as e:
out.append(ItemResult(i, False, str(e)))
return out # 调用方自行决定:全失败则整体回滚
5. 统一的约定怎么写
| 约定项 | 内容 |
|---|---|
| 异常基类 | 一个项目一个根异常 |
| 分类 | 可重试 / 不可重试 / 用户可见 |
| 日志位置 | 只在顶层记录一次 |
| 对外映射 | 异常 → HTTP 状态与错误码 |
第四项(对外映射)要求异常带"是否对用户可见"的信息:内部错误的堆栈不能出现在响应里,而参数错误的细节必须给出。这需要在异常上区分"内部信息"与"对外信息"两个字段。
class ProjectError(Exception):
code: str = "INTERNAL"
http_status: int = 500
public_message: str = "服务暂时不可用" # 对外
# 内部细节放 args / __cause__,只进日志
第三项是最容易做错的:在每一层都 log.exception 会让同一次失败产生四条日志,而排查时无法判断是四个问题还是一个。正确做法是"底层不记,顶层记一次"。
// 只在顶层记录:一处异常 = 一条日志
@app.exception_handler(ProjectError)
def handle(request, exc: ProjectError):
log.error("request_failed code=%s path=%s", exc.code, request.url.path, exc_info=True)
return JSONResponse({"code": exc.code, "message": exc.public_message}, status=exc.http_status)
6. 排错:五种"错误被吞掉"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 接口返回成功但数据没变 | 返回码未判断 | 改为异常或显式检查 |
| 日志里同一个错误出现四次 | 每层都记录 | 只在顶层记 |
| 无法区分"没有"与"出错" | None 双语义 |
引入结果对象 |
| 升级库后 except 失效 | 外部异常类型泄漏 | 适配层转换 |
| 部分失败被当成全部失败 | 用异常表达批量 | 改逐项结果 |
第五项(批量用异常)还有一种变形:用异常做控制流——"没找到"也抛异常。这在"通常能找到"的场景下没问题,但在"通常找不到"的场景下会让异常成为常态,性能与可读性双输。
第四项很隐蔽:except SomeLibError 在库升级后可能永远不触发,代码不报错,只是错误路径静默失效。
7. 边界:什么情况不必纠结
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性脚本 | 不长期维护 | 直接抛 |
| 不可能失败的操作 | 无错误路径 | 不处理 |
| 纯内部工具(有人盯着) | 失败立刻可见 | 返回码可用 |
| 已由框架统一处理 | 有约定 | 跟随框架 |
还有一条与"约定落地"有关的边界:写进文档的约定不会被执行,写进 lint 与基类的才会。所以"统一异常基类 + 一个顶层处理器"比一份规范文档有效得多。
判断口径一句话:这个错误"忽略之后会不会做出错误的事"。会,用异常(无法忽略);不会但需要分支,用结果对象;只是"记录一下",返回码够用。
8. 自测
- 三种方式的代表代价各是什么?为什么"返回码被忽略"不会有任何提示?
- 为什么异常只应在适配层转换一次?不做转换时升级库会发生什么?
None同时表示"没有"与"出错"会造成什么后果?结果对象如何解决?- 什么场景最适合结果对象而非异常?举一个具体例子。
- 为什么"每层都 log.exception"会让排查变难?正确做法是什么?
- 判断"该用哪种错误传递方式"的那个问题是什么?