跳到主要内容

Python 异常处理与边界设计

异常的本质是告知调用者:当前操作未能履行其承诺。

捕获异常(catch)应遵循最小化原则:仅在程序能够恢复、需要将底层错误转换为更高层的业务语义,或需要补充上下文信息以便向上层传递时,才进行捕获。

class ConfigurationError(ValueError):
pass

def load_port(raw: str) -> int:
try:
port = int(raw)
except ValueError as error:
raise ConfigurationError(f"invalid port: {raw!r}") from error

if not 1 <= port <= 65_535:
raise ConfigurationError("port is outside the valid range")
return port

使用 raise ... from error 可以显式地建立异常链,保留原始错误原因。只有当屏蔽底层细节能显著提升对外诊断信息的清晰度时,才使用 from None

捕获结构规范

保持 try 块尽可能小,确保 except 只捕获它真正负责处理的那部分逻辑:

try:
document = read_document(path)
except FileNotFoundError:
return default_document()
else:
return parse_document(document)
  • else:仅在 try 块成功执行且未抛出异常时运行。
  • finally:无论正常退出还是异常退出都会执行,适用于必须执行的清理工作。但在资源管理场景下,使用上下文管理器(with 语句)通常比 try/finally 更清晰、更安全。

捕获策略: 优先捕获具体的异常子类。except Exception 仅适用于进程、请求或 Worker 的顶层边界,用于记录日志并隔离意外故障。严禁在业务逻辑中静默吞掉异常或将错误转化为成功状态。

注意:KeyboardInterruptSystemExit 等终止信号直接继承自 BaseException 而非 Exception,通常应让其继续向上传播,不要拦截。

自定义错误类

  1. 复用内置异常:如果内置异常(如 ValueError, IOError)语义匹配,直接复用,不要过度设计。
  2. 领域异常层级:库应暴露一个以单一公共异常为根的小型领域异常层级,允许调用者根据需求选择宽泛或精确的捕获粒度。
  3. 结构化数据:如果调用者需要程序化处理失败,应在异常对象中存储结构化的属性,而不是强迫调用者去解析错误消息字符串。

安全与日志原则: 异常消息应描述“发生了什么”,严禁泄露密钥、Token 或完整的敏感载荷。避免在每一层都记录日志并重新抛出同一异常,这会产生大量重复噪音。应选择拥有诊断职责的边界层进行记录。

断言(Assert)不是验证

assert 用于记录开发者认为“绝不可能发生”的内部不变量。

由于 Python 在优化模式(-O)下会移除 assert 语句,因此严禁使用 assert 来验证用户输入、权限、配置或其他必需的运行时条件。对于这类场景,必须抛出适当的异常。

测试异常契约

测试应关注异常的公共契约,而非实现细节:

import pytest

def test_load_port_rejects_out_of_range_value() -> None:
with pytest.raises(ConfigurationError, match="outside"):
load_port("70000")

测试要点:

  • 验证抛出的异常类型是否正确。
  • 验证异常对象的关键属性。
  • 验证异常抛出后,系统状态是否保持一致(无副作用泄漏)。
  • 验证资源清理是否执行。

除非精确的错误文本是对外 API 的一部分,否则不要断言完整的 Traceback 或不稳定的措辞。

清理与异常抑制

只有 try 正常执行完毕,else 才会运行;执行 returnbreakcontinue 后不会进入 elseelse 中抛出的异常不会被前面的 except 捕获。Python 正常展开调用栈时,即使提前返回也会执行 finally。但 finally 中的 return 会覆盖原返回值,甚至吞掉待传播的异常,因此清理代码应避免这类控制流。进程被强制终止时,不能保证清理会执行。

contextmanager 装饰的生成器必须恰好 yield 一次:进入时运行到 yield,退出时继续执行。with 块抛出的异常会在该暂停点重新抛入生成器。用 finally 释放资源,同时让异常正常传播:

from contextlib import contextmanager
from io import StringIO

@contextmanager
def text_buffer(text):
handle = StringIO(text)
try:
yield handle
finally:
handle.close()

try:
with text_buffer("hello") as handle:
assert handle.read() == "hello"
raise ValueError("demo")
except ValueError:
assert handle.closed

StringIO 本身就支持 with;包装它只是为了演示协议,并非必要抽象。用类实现时,__enter__ 提供绑定给 as 的值,__exit__ 接收异常信息。__exit__ 返回真值会抑制异常。如果 __enter__ 失败,Python 不会调用 __exit__,因此进入阶段需要自行清理已获取的部分资源。

参考资料

探索关联打开关联网络