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 的顶层边界,用于记录日志并隔离意外故障。严禁在业务逻辑中静默吞掉异常或将错误转化为成功状态。
注意:KeyboardInterrupt、SystemExit 等终止信号直接继承自 BaseException 而非 Exception,通常应让其继续向上传播,不要拦截。
自定义错误类
- 复用内置异常:如果内置异常(如
ValueError,IOError)语义匹配,直接复用,不要过度设计。 - 领域异常层级:库应暴露一个以单一公共异常为根的小型领域异常层级,允许调用者根据需求选择宽泛或精确的捕获粒度。
- 结构化数据:如果调用者需要程序化处理失败,应在异常对象中存储结构化的属性,而不是强迫调用者去解析错误消息字符串。
安全与日志原则: 异常消息应描述“发生了什么”,严禁泄露密钥、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 才会运行;执行 return、break 或 continue 后不会进入 else。else 中抛出的异常不会被前面的 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__,因此进入阶段需要自行清理已获取的部分资源。