Python 基础体系 · 第 18/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 异常体系:传播、链、分组、清理和错误契约

异常不是“打印错误信息”的语法糖,而是 Python 运行模型中的一种控制流机制:程序在某个执行点创建并抛出异常对象,当前代码块停止正常执行,解释器沿调用关系寻找能够匹配该异常类型的处理器;如果始终找不到,程序终止或返回交互式主循环,并输出 traceback。(docs.python.org)

理解异常体系,至少要同时掌握六个问题:

  1. 异常对象如何被创建、抛出和匹配;
  2. 异常如何跨函数调用传播;
  3. 原始错误如何通过异常链保留下来;
  4. 多个并发或批处理错误如何组成异常组;
  5. 清理代码如何在成功、失败和清理失败时运行;
  6. 函数或模块如何用异常类型建立稳定的错误契约。

一、异常是什么:一种可传播的控制流对象

1. 运行错误与语法错误不同

Python 中至少有两类常见错误:

  • 语法错误:解析源代码时发现程序结构不合法,例如缺少冒号;
  • 异常:语法正确,但执行过程中无法完成某个操作,例如除零、名称不存在、类型不匹配。(docs.python.org)
while True print("hello")

这段代码在执行前就无法通过解析,因此产生 SyntaxError。而下面的代码可以通过解析,异常发生在运行阶段:

result = 10 / 0

其执行过程可以抽象为:

解析源代码
  └─ 成功
      └─ 执行表达式 10 / 0
          └─ 发现除数为 0
              └─ 创建 ZeroDivisionError
                  └─ 开始寻找处理器

异常本身是一个对象,通常是某个异常类的实例。except 通过异常类进行匹配,而不是通过错误消息文本匹配。异常消息不是稳定 API,Python 文档明确指出,消息内容可能在不同版本之间变化,跨版本程序不应依赖它进行判断。(docs.python.org)

try:
    int("abc")
except ValueError as exc:
    print(type(exc).__name__)
    print(exc.args)
    print(str(exc))

典型输出为:

ValueError
("invalid literal for int() with base 10: 'abc'",)
invalid literal for int() with base 10: 'abc'

这里有三个不同层次的信息:

  • type(exc):异常对象的具体类型;
  • exc.args:构造异常时传入的位置参数;
  • str(exc):异常对象的可读文本表示。

工程代码应优先依据异常类型和明确的结构化属性做判断,而不是解析 str(exc)


二、异常层次:BaseExceptionException

内置异常的根类是 BaseException。通常应用代码只处理它的子类 Exception,而不应该无条件捕获 BaseException。核心层次如下:

BaseException
├── BaseExceptionGroup
├── GeneratorExit
├── KeyboardInterrupt
├── SystemExit
└── Exception
    ├── ArithmeticError
    │   ├── FloatingPointError
    │   ├── OverflowError
    │   └── ZeroDivisionError
    ├── OSError
    │   ├── FileNotFoundError
    │   ├── PermissionError
    │   └── ConnectionError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── ValueError
    ├── TypeError
    ├── RuntimeError
    └── ExceptionGroup

完整的内置层次还包括 ImportErrorNameErrorAssertionErrorMemoryError 等。(docs.python.org)

KeyboardInterruptSystemExit 不继承自 Exception

try:
    raise KeyboardInterrupt
except Exception:
    print("不会执行")

因此,下面这种写法通常是错误的:

try:
    run_application()
except BaseException:
    log_fatal_error()

它会把用户按下 Ctrl-C 的中断、sys.exit() 触发的退出信号也拦截下来,使程序无法正常退出。

更合理的边界通常是:

try:
    run_application()
except Exception:
    logger.exception("application failed")
    raise

这里捕获的是常规应用异常;意外异常被记录后重新抛出,仍然交给更外层的进程边界处理。


三、异常传播:从当前执行点向调用者寻找处理器

3.1 传播的基本规则

考虑下面的调用关系:

main()
  └─ load_config()
      └─ read_file()
          └─ open()

如果 open() 抛出 FileNotFoundError,且 read_file()load_config() 都没有匹配的处理器,异常会逐层向上返回,直到 main() 或更外层代码处理它。

def read_file(path):
    with open(path, encoding="utf-8") as file:
        return file.read()


def load_config():
    return read_file("missing.toml")


def main():
    try:
        load_config()
    except FileNotFoundError as exc:
        print("配置文件不存在:", exc.filename)


main()

传播过程是:

open("missing.toml")
  └─ 抛出 FileNotFoundError
      └─ 离开 read_file()
          └─ 离开 load_config()
              └─ 到达 main() 的 try
                  └─ FileNotFoundError 匹配
                      └─ 执行 except
                          └─ 继续执行 try 语句之后的代码

异常处理器不仅能够处理 try 代码块中直接写出的异常,也能处理被调用函数间接抛出的异常。(docs.python.org)

3.2 except 的匹配是“类型兼容”,不是字符串相等

如果异常实例的类型是 C,则以下处理器可以匹配:

class A(Exception):
    pass


class B(A):
    pass


class C(B):
    pass
try:
    raise C()
except A:
    print("matched A")

输出:

matched A

因为 CA 的子类实例。

多个 except 从上到下检查,最多执行一个处理器:

try:
    raise C()
except A:
    print("A")
except B:
    print("B")
except C:
    print("C")

输出是:

A

except A 已经匹配成功,后面的处理器不会再次执行。因此,具体异常应放在前面,宽泛异常放在后面:

try:
    operation()
except FileNotFoundError:
    recover_missing_file()
except OSError:
    recover_other_os_error()

如果调换顺序,FileNotFoundError 会先被 OSError 捕获,具体处理逻辑就永远不会执行。

3.3 else 缩小受保护区域

tryelse 只在 try 代码块正常完成后执行,并且 else 中发生的异常不会被前面的 except 捕获。(docs.python.org)

try:
    raw = file.read()
except OSError:
    handle_read_error()
else:
    config = parse_config(raw)

这里的契约是:

  • try 只负责处理读取阶段的 OSError
  • else 负责读取成功后的解析;
  • 如果 parse_config() 抛出 ValueError,它会继续向外传播,而不会被当前 except OSError 误认为是读取错误。

反例:

try:
    raw = file.read()
    config = parse_config(raw)
except OSError:
    handle_read_error()

这个例子虽然语法正确,但保护范围过大。若 parse_config() 内部恰好抛出 OSError,调用者会误以为文件读取失败,诊断信息和恢复动作都可能错误。

因此,else 的价值不是“增加一个分支”,而是明确划分异常责任边界。


四、raise:创建、重新抛出与替换异常

4.1 显式抛出异常

raise 可以接受异常实例,也可以接受异常类:

raise ValueError("age must be non-negative")

下面两种写法等价:

raise ValueError
raise ValueError()

异常类必须继承自 BaseException。(docs.python.org)

4.2 裸 raise 保留当前异常

except 内部,裸 raise 表示重新抛出当前正在处理的异常:

try:
    parse_payload()
except ValueError:
    logger.exception("payload is invalid")
    raise

它与下面的代码不同:

try:
    parse_payload()
except ValueError as exc:
    raise exc

两者都能继续抛出异常,但裸 raise 表达的是“原样继续传播当前异常”。raise exc 是一次显式抛出操作,调试时可能使 traceback 的展示路径不同,因此在只想转交异常时应使用裸 raise

典型分层代码如下:

def parse_user_id(raw):
    try:
        return int(raw)
    except ValueError:
        raise ValueError("user id must be an integer") from None

这里选择了替换外部可见消息,并通过 from None 隐藏内部转换异常的展示链。后文会解释这并不等于删除因果关系。


五、异常链:保留“为什么发生”和“对外如何表达”

异常链解决的问题是:

底层异常类型适合底层组件,但不适合作为上层 API 的错误契约时,如何转换异常,同时不丢失根因?

5.1 隐式上下文:__context__

当一个异常处理器内部又抛出另一个异常,Python 会把原异常记录为新异常的上下文:

try:
    open("database.sqlite")
except OSError:
    raise RuntimeError("database initialization failed")

逻辑关系是:

FileNotFoundError
  └─ 正在 except OSError 中处理
      └─ 抛出 RuntimeError
          └─ RuntimeError.__context__ = FileNotFoundError

解释器通常显示:

During handling of the above exception, another exception occurred:

这是“处理旧异常时发生了新异常”,不一定表示新异常是旧异常的直接业务原因。

5.2 显式原因:raise ... from ...

如果新异常明确是由旧异常导致的,应使用显式链:

class ConfigLoadError(Exception):
    pass


def load_config(path):
    try:
        with open(path, encoding="utf-8") as file:
            return file.read()
    except OSError as exc:
        raise ConfigLoadError(f"cannot load config: {path}") from exc

关系变为:

OSError
  └─ 作为直接原因
      └─ ConfigLoadError

对象属性大致是:

try:
    load_config("app.toml")
except ConfigLoadError as exc:
    print(type(exc.__cause__).__name__)
    print(exc.__suppress_context__)

典型输出:

FileNotFoundError
True

使用 from exc 后:

  • exc.__cause__ 指向原始异常;
  • exc.__suppress_context__ 为真;
  • traceback 使用“直接原因”关系展示链。

这允许 API 对外提供稳定的领域异常,同时让日志、调试器和诊断工具继续追溯底层原因。

5.3 from None 只隐藏展示,不代表根因不存在

try:
    int("abc")
except ValueError:
    raise ValueError("invalid user input") from None

from None 抑制自动上下文的展示。它适用于底层实现细节不应暴露给最终调用者的场景,但不应被当作“修复异常”的手段。

错误做法:

try:
    call_remote_service()
except Exception:
    raise ServiceError("request failed") from None

这会让所有异常都变成 ServiceError,并隐藏网络错误、序列化错误、程序缺陷等重要信息。更准确的做法是只转换确实属于该领域契约的异常:

try:
    response = transport.request()
except TimeoutError as exc:
    raise ServiceUnavailable("remote service timed out") from exc

5.4 异常链与错误契约的分层

可以把异常传播设计为三层:

底层设施异常
  ├─ FileNotFoundError
  ├─ TimeoutError
  └─ ConnectionError
        │
        │  在边界处转换
        ▼
领域异常
  ├─ ConfigLoadError
  ├─ PaymentGatewayUnavailable
  └─ RepositoryError
        │
        │  在应用边界分类
        ▼
用户响应 / 重试 / 告警 / 进程失败

转换异常时,应保留三个信息:

  1. 对调用者稳定的异常类型;
  2. 面向人的简短描述;
  3. 通过 from exc 保留的根因链。

不要只把底层异常转成字符串:

except OSError as exc:
    raise ConfigLoadError(str(exc))

这样虽然保留了文本,却丢失了类型和因果链。调用者无法再通过 __cause__、异常类型或结构化字段诊断问题。


六、异常对象的附加信息:属性、add_note() 与日志

异常对象可以携带比消息更稳定的信息:

class RecordValidationError(Exception):
    def __init__(self, record_id, field, reason):
        self.record_id = record_id
        self.field = field
        self.reason = reason
        super().__init__(
            f"record {record_id!r}: field {field!r} is invalid: {reason}"
        )

调用者可以这样处理:

try:
    validate_record(record)
except RecordValidationError as exc:
    print(exc.record_id)
    print(exc.field)
    print(exc.reason)

对于已经存在的异常,Python 提供 add_note(note) 添加补充上下文。标准 traceback 会按添加顺序显示这些 note。(docs.python.org)

try:
    process_batch_item(item)
except Exception as exc:
    exc.add_note(f"batch_id={batch_id}")
    exc.add_note(f"item_index={index}")
    raise

add_note() 适合添加动态上下文,例如:

  • 批次编号;
  • 文件路径;
  • 任务索引;
  • 租户标识;
  • 重试次数。

它与日志字段不是同一层次:

  • add_note() 修改异常对象,异常继续传播时上下文仍在;
  • 日志字段属于日志事件,便于检索和聚合;
  • 二者可以同时使用,但不要只依赖异常消息承载机器可读字段。

例如:

try:
    run_job(job_id)
except Exception:
    logger.exception(
        "job failed",
        extra={"job_id": job_id, "operation": "run_job"},
    )
    raise

若日志系统支持结构化异常记录,还应记录异常类型、链和 traceback,而不是只记录 str(exc)


七、清理语义:finally 保证执行,但不保证成功

7.1 finally 的控制流

finally 用于清理,它不负责匹配异常,而是在 tryexceptelse 执行后运行。若此前有未处理异常,finally 完成后异常会继续抛出。(docs.python.org)

resource = acquire()

try:
    use(resource)
except ValueError:
    recover()
finally:
    release(resource)

可能的路径如下:

try 成功
  └─ finally
      └─ 正常结束

try 抛出,except 处理
  └─ finally
      └─ 正常结束

try 抛出,except 不匹配
  └─ finally
      └─ 原异常继续传播

finally 自身抛出新异常
  └─ 新异常继续传播
      └─ 原异常成为新异常的 __context__

7.2 finally 中抛出异常会覆盖原异常

def bad_cleanup():
    try:
        raise ValueError("operation failed")
    finally:
        raise RuntimeError("cleanup failed")

最终向外传播的是 RuntimeError,而 ValueError 位于它的异常上下文中。

这说明清理代码也必须有错误策略。资源释放失败并不总是可以忽略,但也不能无意中遮蔽主操作失败。

一种保留主异常的写法是:

def close_quietly(resource):
    try:
        resource.close()
    except Exception:
        logger.exception("resource close failed")

这种写法的取舍是:关闭失败被记录,但不覆盖主异常。是否忽略清理异常取决于资源语义。例如临时文件删除失败可能需要告警,而事务回滚失败通常必须升级为高优先级错误。

7.3 不要在 finallyreturn

def misleading():
    try:
        return "operation result"
    finally:
        return "cleanup result"

返回值是:

cleanup result

如果 try 中发生异常,finally 中的 return 还会丢弃该异常。Python 3.14 编译器会针对 finally 中出现的 returnbreakcontinue 发出 SyntaxWarning,因为这类控制流容易隐藏异常。(docs.python.org)


八、with:把清理协议封装为上下文管理器

with 不是特殊的“文件语法”,而是对上下文管理协议的使用。一个同步上下文管理器至少提供:

__enter__(self)
__exit__(self, exc_type, exc_value, traceback)

其生命周期可以近似理解为:

manager = expression
enter = type(manager).__enter__
exit = type(manager).__exit__

value = enter(manager)
try:
    body(value)
except BaseException as exc:
    if not exit(manager, type(exc), exc, exc.__traceback__):
        raise
else:
    exit(manager, None, None, None)

这段代码用于理解控制流,不是语言规范中的逐字展开。真正的语义包括:先求值上下文表达式,再取得并调用 __enter__();退出代码块时调用 __exit__(),并将异常信息传给它。(docs.python.org)

8.1 __exit__ 的返回值决定是否抑制异常

class SuppressValueError:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        return exc_type is ValueError


with SuppressValueError():
    raise ValueError("ignored")

print("continues")

输出:

continues

如果 __exit__() 返回真值,异常被抑制;返回假值,异常继续传播。

上下文管理器因此拥有两个职责:

  1. 资源生命周期管理;
  2. 异常处理策略表达。

资源管理器通常不应无条件返回 True

def __exit__(self, exc_type, exc_value, traceback):
    release_resource()
    return True

这会把资源使用期间的所有异常都吞掉,导致调用者误以为操作成功。除非上下文管理器的明确语义就是“抑制某类异常”,否则应返回 FalseNone

8.2 __enter__ 失败时,__exit__ 不负责善后

class Demo:
    def __enter__(self):
        print("enter")
        raise RuntimeError("enter failed")

    def __exit__(self, *args):
        print("exit")


with Demo():
    print("body")

输出只有:

enter

因为 __enter__() 尚未成功完成,with 主体没有开始执行,__exit__() 也不会被调用。

这对自定义资源管理器很重要:如果 __enter__() 在获取部分资源后继续执行,并且后续初始化失败,必须在 __enter__() 内部自己清理已经获取的资源,或者使用 ExitStack 管理中间状态。


九、ExitStack:动态资源集合的回滚栈

固定数量的资源适合嵌套 with

with open("a.txt") as first:
    with open("b.txt") as second:
        process(first, second)

但当资源数量由配置、输入或运行时条件决定时,嵌套结构会变得笨重。ExitStack 将清理动作注册到一个栈中,并在退出时按后进先出顺序执行。官方文档将它定位为管理可变数量上下文管理器和其他清理操作的工具。(docs.python.org)

from contextlib import ExitStack


def read_all(paths):
    with ExitStack() as stack:
        files = [
            stack.enter_context(open(path, encoding="utf-8"))
            for path in paths
        ]
        return [file.read() for file in files]

假设:

paths = ["a.txt", "missing.txt", "c.txt"]

执行流程是:

打开 a.txt
  └─ 注册 a.txt.close

打开 missing.txt
  └─ 抛出 FileNotFoundError

ExitStack 开始回滚
  └─ 调用 a.txt.close

FileNotFoundError 继续传播

因此,先打开的资源不会因为后续资源获取失败而泄漏。

9.1 注册普通清理函数

不是所有资源都有 __enter____exit__。可以使用 callback()

from contextlib import ExitStack


def release_lock(lock_id):
    print(f"release {lock_id}")


with ExitStack() as stack:
    lock_id = "lock-42"
    stack.callback(release_lock, lock_id)
    print("work")

输出:

work
release lock-42

清理动作在注册时就确定,因此不需要额外的布尔标志:

cleanup_needed = True
try:
    result = operation()
    if result:
        cleanup_needed = False
finally:
    if cleanup_needed:
        cleanup()

可以改写为:

with ExitStack() as stack:
    stack.callback(cleanup)
    result = operation()

    if result:
        stack.pop_all()

pop_all() 将清理回调转移出去,使当前 with 不再执行它。ExitStack 的回调按注册逆序执行。(docs.python.org)


十、异常分组:从“第一个失败”到“同时报告多个失败”

普通异常传播模型一次只能携带一个当前异常:

任务 A 失败 ──┐
任务 B 失败 ──┼─ 只能选择一个先抛出
任务 C 失败 ──┘

并发任务、批量校验和多目标操作经常需要保留多个失败结果。ExceptionGroup 可以把多个异常实例组合为一个异常;它本身也是异常,可以被普通 except 捕获。ExceptionGroup 自 Python 3.11 引入,Python 3.14 继续适用。(docs.python.org)

errors = []

for index, value in enumerate(["10", "bad", None]):
    try:
        int(value)
    except Exception as exc:
        exc.add_note(f"item_index={index}")
        errors.append(exc)

if errors:
    raise ExceptionGroup("batch conversion failed", errors)

这个组包含两个异常实例:

ValueError
TypeError

注意,构造 ExceptionGroup 时必须传入异常实例,而不是异常类型:

ExceptionGroup("bad", [ValueError, TypeError])

这是错误的,会产生 TypeError。正确方式是:

ExceptionGroup("ok", [ValueError("bad value"), TypeError("bad type")])

10.1 except Exceptionexcept*

普通 except 把整个异常组作为一个对象处理:

try:
    raise ExceptionGroup(
        "validation failed",
        [ValueError("bad value"), TypeError("bad type")],
    )
except Exception as exc:
    print(type(exc).__name__)
    print(len(exc.exceptions))

输出:

ExceptionGroup
2

如果需要按成员类型分别处理,应使用 except*

try:
    raise ExceptionGroup(
        "validation failed",
        [
            ValueError("bad value"),
            TypeError("bad type"),
            ValueError("another bad value"),
        ],
    )
except* ValueError as group:
    print("value errors:", len(group.exceptions))
except* TypeError as group:
    print("type errors:", len(group.exceptions))

输出:

value errors: 2
type errors: 1

except* 的匹配对象不是整个组,而是递归筛选组中匹配类型的成员。每个 except* 处理器获得一个保留原嵌套结构的子组;未匹配部分继续交给后续处理器,最终未处理异常与处理器中新抛出或重新抛出的异常合并后继续传播。(docs.python.org)

10.2 exceptexcept* 不能混用

同一个 try 语句不能同时出现 exceptexcept*

try:
    operation()
except Exception:
    pass
except* ValueError:
    pass

这是语法错误。应该根据错误模型选择一种方式:

  • 只关心“整体失败”:使用 except ExceptionGroupexcept Exception
  • 需要拆分并分别恢复:使用一个或多个 except*

except* 处理器中也不能使用 returnbreakcontinue。(docs.python.org)

10.3 异常组不是普通列表

异常组保留:

  • 组消息;
  • 成员的嵌套结构;
  • 各成员的 traceback;
  • 异常链;
  • notes。

因此,处理异常组时不要简单地把 group.exceptions 扁平化为字符串列表,否则可能丢失每个异常原本的因果和调用位置。


十一、错误契约:异常是 API 的一部分

错误契约是调用者可以依赖的失败行为,包括:

  • 哪些输入会失败;
  • 抛出什么异常类型;
  • 异常对象携带哪些属性;
  • 哪些异常会被转换;
  • 哪些异常会继续传播;
  • 操作失败后资源是否已经清理;
  • 批量操作是否抛出单个异常还是异常组。

11.1 输入验证契约

class InvalidPageSize(ValueError):
    def __init__(self, value):
        self.value = value
        super().__init__(f"invalid page size: {value!r}")


def fetch_page(page_size):
    if not isinstance(page_size, int):
        raise TypeError("page_size must be int")
    if page_size <= 0:
        raise InvalidPageSize(page_size)

这里有两个不同契约:

  • 类型错误使用 TypeError
  • 类型正确但值不合法使用 ValueError 的子类。

调用者可以稳定地区分:

try:
    fetch_page(size)
except InvalidPageSize as exc:
    return {"error": "invalid_page_size", "value": exc.value}
except TypeError:
    return {"error": "programmer_error"}

11.2 不要为了“统一”捕获所有异常

下面的代码破坏了错误契约:

def fetch_page(page_size):
    try:
        ...
    except Exception:
        return None

它把以下情况全部压成 None

  • 用户输入非法;
  • 网络连接失败;
  • 数据库连接池耗尽;
  • 程序中的 AttributeError
  • 内存错误之外的许多运行时缺陷。

调用者无法判断是“没有数据”还是“程序失败”,重试、告警和回滚也无法正确进行。

更明确的做法是:

def fetch_page(page_size):
    try:
        return repository.fetch(page_size)
    except TimeoutError as exc:
        raise RepositoryUnavailable("repository timeout") from exc

未知异常不转换,继续传播。转换应该发生在真正理解底层异常含义的边界。

11.3 文档字符串应描述失败路径

def load_user(user_id):
    """
    加载用户。

    Raises:
        TypeError: user_id 不是整数。
        UserNotFound: 用户不存在。
        UserRepositoryError: 存储层访问失败。
    """

这里的 Raises 不是装饰语法,而是对调用者的行为承诺。若实现实际还会抛出未文档化的 KeyErrorAttributeError,说明实现边界或异常转换设计存在问题。


十二、完整示例:资源安全、异常链、异常组和日志

下面的例子模拟批量加载配置文件:

from contextlib import ExitStack
import logging
from pathlib import Path


logger = logging.getLogger(__name__)


class ConfigLoadError(Exception):
    def __init__(self, path, reason):
        self.path = Path(path)
        self.reason = reason
        super().__init__(f"cannot load config {self.path}: {reason}")


def load_one(path):
    try:
        with open(path, encoding="utf-8") as file:
            text = file.read()
    except OSError as exc:
        raise ConfigLoadError(path, "read failed") from exc

    if not text.strip():
        raise ConfigLoadError(path, "empty file")

    return text


def load_many(paths):
    errors = []
    loaded = {}

    with ExitStack():
        for path in paths:
            try:
                loaded[path] = load_one(path)
            except ConfigLoadError as exc:
                exc.add_note(f"path={path}")
                errors.append(exc)

    if errors:
        raise ExceptionGroup("config loading failed", errors)

    return loaded


try:
    configs = load_many(["app.toml", "worker.toml", "missing.toml"])
except* ConfigLoadError as group:
    for error in group.exceptions:
        logger.error(
            "config load error",
            extra={
                "path": str(error.path),
                "reason": error.reason,
            },
        )

执行路径如下:

load_many()
  └─ 创建 ExitStack
      ├─ load_one("app.toml")
      │   └─ 成功,加入 loaded
      ├─ load_one("worker.toml")
      │   └─ 失败
      │       └─ OSError
      │           └─ ConfigLoadError
      │               └─ __cause__ = OSError
      │               └─ add_note(path=...)
      │               └─ 收集到 errors
      ├─ load_one("missing.toml")
      │   └─ 失败并收集
      └─ ExitStack 退出并执行已注册的清理
          └─ errors 非空
              └─ raise ExceptionGroup
                  └─ except* ConfigLoadError 拆分处理

这个设计有几个重要性质:

  • 单个文件错误不会丢失原始 OSError
  • 多个文件错误可以一次报告;
  • 成功加载的结果仍然保存在局部变量中;
  • 调用者可以按 ConfigLoadError 处理这一类失败;
  • 日志使用结构化字段,而不是从错误文本中提取路径;
  • 未预期的异常不会被无条件吞掉。

如果业务要求“任何一个配置失败都不能继续”,则不应收集后继续,而应在第一个失败时直接传播。异常组的价值取决于调用者是否真正需要多个失败结果。


十三、诊断异常的顺序

遇到异常时,可以按以下顺序读取:

1. 先看最后一行异常类型

FileNotFoundError
ValueError
TypeError

它通常决定第一步应查找哪类操作。

2. 再看 traceback 最底部的业务代码位置

最后一个用户代码栈帧通常接近异常发生点,但“显示位置”不一定就是根本原因。例如参数在更早的位置已经错误,只是在后续运算时才暴露。

3. 检查异常链

except Exception as exc:
    print(exc.__cause__)
    print(exc.__context__)
  • __cause__:显式 raise ... from ... 指定的直接原因;
  • __context__:处理一个异常期间自然产生的新异常上下文。

4. 检查 notes 和结构化属性

print(getattr(exc, "__notes__", None))
print(vars(exc))

自定义异常中的 pathrecord_idstatus_code 等属性,通常比消息文本更适合自动化处理。

5. 异常组必须递归理解

对于 ExceptionGroup,不能只看顶层类型。应检查:

except ExceptionGroup as group:
    for item in group.exceptions:
        print(type(item).__name__, item)

实际生产代码还要考虑嵌套组;except* 通常比手动递归更适合按类型筛选。


十四、常见误区与失败表现

误区一:用 except: 处理普通业务错误

try:
    operation()
except:
    return fallback()

它会捕获 KeyboardInterruptSystemExit 等不应被普通恢复逻辑拦截的异常。至少应使用:

except Exception:
    return fallback()

并且只在确实能够恢复时捕获。

误区二:捕获后什么都不做

try:
    operation()
except Exception:
    pass

这会同时删除失败信号、traceback 和调用者的恢复机会。若确实忽略异常,也应让代码意图明确,并限定异常类型:

try:
    cache.remove(key)
except KeyError:
    pass

误区三:重新抛出时丢失根因

except OSError as exc:
    raise ConfigLoadError(str(exc))

应改为:

except OSError as exc:
    raise ConfigLoadError("config read failed") from exc

前者只复制文本,后者保留直接原因。

误区四:在 finally 中关闭异常路径

try:
    operation()
finally:
    return default_value

这会让调用者看不到 operation() 的失败。清理代码应释放资源,不应改变主控制流。

误区五:把异常组当成单个错误消息

except ExceptionGroup as group:
    print(str(group))

这只能得到组摘要,无法对不同成员分别恢复。若不同错误类型有不同策略,应使用 except*

误区六:把日志当成错误契约

日志可能被采样、截断、轮转或配置为不同级别;调用者不能通过“日志里出现某句话”判断 API 失败类型。API 应通过异常类型和属性表达契约,日志负责诊断和观测。


结语:异常体系的核心关系

Python 异常模型可以压缩成一条完整因果链:

异常对象被创建
  └─ raise
      └─ 当前代码块停止正常执行
          └─ 按调用关系向外传播
              └─ 按异常类型匹配处理器
                  ├─ 处理并恢复
                  ├─ 裸 raise 继续传播
                  ├─ raise ... from ... 转换并保留根因
                  ├─ ExceptionGroup 携带多个失败
                  └─ finally / with / ExitStack 执行清理

其中最重要的边界是:

  • 异常类型表达可机器判断的失败分类;
  • 异常属性表达稳定的结构化上下文;
  • 异常链表达底层原因与上层语义之间的因果关系;
  • 异常组表达多个并行或批量失败;
  • finally 与上下文管理器表达资源生命周期;
  • 错误契约规定调用者可以依赖哪些失败行为。

当这些关系清晰时,异常就不再只是 traceback 的终点,而成为函数边界、资源安全、并发汇总和系统诊断之间的连接协议。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。