Python 错误处理

Python 以异常为主错误路径:try/except/else/finally、捕获粒度、raise from 因果链,以及何时翻译异常、何时让其冒泡。

#type / concept #status / growing #tech / dev #resource / python #tech / lang / python

[!info] 关联笔记

Python 错误处理

这个概念为什么出现

真实系统里失败是常态:

  • 用户输入不是整数
  • 余额不足
  • 下游 JSON 坏了
  • 磁盘满了

若没有统一错误模型,代码会变成:

  • 到处 if err != None 风格却又不彻底
  • print 后继续跑,数据写一半
  • 捕获过宽,把 KeyboardInterrupt 和业务失败一锅炖
  • 日志只有 something failed,没有因果链

Python 选择:异常是常规失败路径的一等机制。成功走返回值,失败抛出,由合适的边界捕获、翻译或记录。

这与 Go “error 值显式返回”不同,但目标一致——让失败可见、可分类、可清理

[!abstract] 一句话理解 用 try/except/else/finally 管理失败控制流;捕获要具体;清理放 finally 或上下文管理器;跨边界翻译错误时用 raise ... from ... 保留根因。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:积分兑换既要防坏输入,也要表达业务失败

兑换服务收到字符串形式的积分花费:

  • 解析失败 → 输入错误(可映射 HTTP 400)
  • 余额不足 → 业务错误(可映射 409/400 业务码)
  • 无论成败 → 写审计收尾(对应 finally
# points_redeem_errors.py
# 业务意图:兑换积分,区分输入错误与业务失败,并保证审计收尾。
# 教学点:
# - 捕获具体异常;
# - else 仅成功路径;
# - finally 必执行;
# - raise from 保留因果。

from __future__ import annotations


class InsufficientPointsError(Exception):
    """余额不足:可识别的业务失败。"""


def parse_points(raw: str) -> int:
    # 输入边界:必须是正整数。
    points = int(raw)  # 非数字 -> ValueError
    if points <= 0:
        raise ValueError("points must be positive")
    return points


def redeem(balance: int, raw_cost: str) -> int:
    print("audit: start")
    try:
        cost = parse_points(raw_cost)
        if cost > balance:
            raise InsufficientPointsError(f"need {cost}, have {balance}")
    except ValueError as exc:
        # 翻译为统一输入错误,同时保留 cause 便于排障。
        raise ValueError(f"invalid cost: {raw_cost!r}") from exc
    else:
        # 仅当 try 未抛异常:执行扣减。
        new_balance = balance - cost
        print(f"audit: success cost={cost}")
        return new_balance
    finally:
        # 成功、业务失败、输入失败都会到这里。
        print("audit: end")


def main() -> None:
    print("case ok:", redeem(100, "30"))

    try:
        redeem(100, "x")
    except ValueError as exc:
        print("case bad input:", exc)
        print("  cause:", type(exc.__cause__).__name__)

    try:
        redeem(10, "30")
    except InsufficientPointsError as exc:
        print("case business:", exc)


if __name__ == "__main__":
    main()

建议运行:

python points_redeem_errors.py

期望输出:

audit: start
audit: success cost=30
audit: end
case ok: 70
audit: start
audit: end
case bad input: invalid cost: 'x'
  cause: ValueError
case business: need 30, have 10

audit: end 在失败路径也会打印;业务失败路径同样有 start/end。)

结合场景再看四个关注点

  1. 类型区分失败模式:输入 vs 余额不足,调用方可分别映射。
  2. else 避免把成功逻辑放进 try 导致误捕获。
  3. finally 保证审计收尾,不依赖每条 return 前手写。
  4. raise from 让日志能挖到 int('x') 根因。

核心概念与准确模型

1. 语句结构

try:
    ...
except SomeError as exc:
    ...
except AnotherError:
    ...
else:
    ...   # try 完全未异常
finally:
    ...   # 几乎总是执行(清理)

执行直觉:

flowchart TD
  T[try 体] -->|成功| E[else]
  T -->|SomeError| X1[except SomeError]
  T -->|其它异常| P[向外传播]
  E --> F[finally]
  X1 --> F
  P --> F
  F --> R[继续/传播]

2. 异常是对象

3. 抛出

写法含义
raise ValueError("...")抛新异常
raise在 except 中原样重抛
raise New from exc显式因果链 __cause__
raise New from None抑制上下文(少用)

4. 捕获粒度

做法评价
具体类型首选
except Exception仅边界层,必须记录
except:禁止(过宽)
except BaseException几乎不要(含退出/取消类)

5. EAFP vs LBYL

  • EAFP(Easier to Ask Forgiveness than Permission):先做,不行再捕——Pythonic 常见风格
  • LBYL(Look Before You Leap):先检查再做——适合检查便宜且避免异常热路径

例:字典键用 in/get 往往比依赖 KeyError 更清晰;文件存在检查则有 TOCTOU 竞态,EAFP 打开更稳。

6. 与上下文管理器

资源清理优先:

with open(path) as f:
    ...

而不是手写层层 try/finally。见 上下文管理器

设计动机

  1. 失败不沉默:异常打断正常返回,迫使边界处理或崩溃暴露问题
  2. 类型可分类:比布尔失败位更易分支
  3. 栈轨迹可观测:配合 logging.exception
  4. 开放扩展:用户可定义领域异常体系

代价:滥用异常做常规分支会难读、偏慢;捕获过宽会隐藏缺陷。

边界情况与反直觉行为

1. finallyreturn

会掩盖 try 的 return/异常——极度不推荐

2. 异常发生在 exceptfinally

可能改变传播中的异常;需要刻意设计。

3. else 的价值

把“成功后逻辑”放 else,避免被同一 tryexcept 误伤。

4. 生成器与 throw/close

生成器清理与 GeneratorExit 有特殊路径(进阶)。

5. asyncio 取消

CancelledError 不是普通 Exception;错误处理模板不能误吞。见 任务、超时与取消

6. 性能

异常路径比分支重;不要用异常做超热路径的期望控制流(如逐字符解析)。

常见误区

[!warning] 常见误区:捕获后 pass 错误理解:程序不崩就是成功。
正确模型:至少记录、翻译或返回明确领域错误。

[!warning] 常见误区:所有失败都 ValueError/Exception 错误理解:省类型。
正确模型:调用方需要分支的失败应可区分。

[!warning] 常见误区:用返回 None 表示一切失败 错误理解:简单。
正确模型:None 与失败混淆;缺值用 Optional,失败用异常或 Result 型设计。

[!warning] 常见误区:在底层库 basicConfig + 吞异常 错误理解:对用户好。
正确模型:库少捕多抛;应用边界统一翻译与日志。

[!warning] 常见误区:把 Go 的 error 值思维硬套成“禁止异常” 错误理解:异常不 Pythonic。
正确模型:Python 标准库与生态以异常为主;关键是边界策略,不是消灭异常。

与相邻概念对比

文化主路径典型
Python 异常抛/捕本篇
Go error 值返回 errorgo-error-handling
Result 类型Ok/Err 容器某些函数式库
HTTP 状态码边界映射Web 层

文字说明:在服务边界,把领域异常映射为稳定对外错误码;在内部,保留类型与因果链。

工程实践

分层策略

  1. 核心域:抛领域异常,少捕获
  2. 适配层(HTTP/CLI):捕获 → 日志 → 用户信息/状态码
  3. 基础设施:翻译第三方异常,保留 from

日志

logger.exception("redeem failed user=%s", user_id)

带堆栈;对外消息与对内细节分离。

测试

  • 成功路径
  • pytest.raises(ValueError)
  • 对链式异常可断言 __cause__

API 映射示例(思想)

异常HTTP
输入 ValueError400
InsufficientPointsError409 或业务码
未知 Exception500 + 关联 ID

可验证实验

实验 1:成功路径

redeem(100, "30") == 70,日志含 success。

实验 2:坏输入

redeem(100, "x")ValueError__cause__ 存在。

实验 3:业务失败

redeem(10, "30")InsufficientPointsError,仍有 audit: end

实验 4:else vs 放入 try

new_balance = ... 放进 try,并故意让其后代码抛 ValueError,观察是否被输入错误分支误伤。

实验 5:裸 except 的危险(只在沙箱)

except: 捕一切,尝试 Ctrl+C 行为(交互环境)——理解为何禁止。

本节总结

  • Python 错误处理的质量 = 分类 + 清理 + 因果 + 边界翻译
  • try/except/else/finally 要按职责拆,而不是一个大 try 包全世界。
  • 与 Go 不同路径,同一工程目标:失败可处理、可观测、不破坏资源。

自测题

概念题

  1. else 与把代码放在 try 末尾的关键差别?
  2. 何时自定义 InsufficientPointsError 而不是统一 ValueError

代码推理题

  1. finally 是否在 return 之前执行?

工程思考题

  1. 库代码捕获 Exception 并返回 None 有什么长期成本?
参考答案
  1. else 中的异常不会被同一 tryexcept 捕获。
  2. 当调用方需要区分输入错误与业务余额不足并走不同处理时。
  3. 会;finally 在离开 try 复合语句前执行。
  4. 失败被静默、调用方无法分支、排障丢失堆栈与类型。

延伸阅读与资料来源

资料类型支撑内容
Errors and Exceptions教程入门
Exception hierarchy文档内置树
raise规范raise/from
EAFP glossary术语EAFP

笔记元信息

  • 角色:原子概念(错误模型主叙事)
  • 深度对齐:go-error-handling 的工程完整度
  • 专篇:层次/链式、上下文管理器、asyncio 取消
创建于 2026/6/6 更新于 2026/7/15