2188 字
约 7 分钟
17
S10-A13 · Python 的错误处理:异常、返回码还是结果对象

一个模块里同时存在三种错误传递方式:底层 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. 自测

  1. 三种方式的代表代价各是什么?为什么"返回码被忽略"不会有任何提示?
  2. 为什么异常只应在适配层转换一次?不做转换时升级库会发生什么?
  3. None 同时表示"没有"与"出错"会造成什么后果?结果对象如何解决?
  4. 什么场景最适合结果对象而非异常?举一个具体例子。
  5. 为什么"每层都 log.exception"会让排查变难?正确做法是什么?
  6. 判断"该用哪种错误传递方式"的那个问题是什么?
S10-A13 · Python 的错误处理:异常、返回码还是结果对象
https://www.clxhxhhr.top/posts/6454/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。