异常、超时、重试与资源管理
本节目标
把失败当作接口的一部分:能区分失败类型,设置总时限,只重试可能恢复且允许重复执行的操作,并保证文件、连接和任务在成功或失败后都被收尾。
先画出失败边界
Agent 工具不是“调用一个函数就结束”。一次操作可能经过输入校验、读取本地文件、访问外部服务、解析结果和持久化输出。每层应只处理自己真正能恢复的异常。
| 失败类型 | 示例 | 通常处理方式 |
|---|---|---|
| 输入/契约错误 | 缺字段、状态值非法 | 立即拒绝;告诉调用方如何修正 |
| 瞬时外部失败 | 短暂连接中断、服务临时不可用 | 在满足重试条件时有限重试 |
| 永久外部失败 | 无权限、资源不存在、参数非法 | 不重试;返回稳定错误类别 |
| 程序缺陷 | 未覆盖的状态、断言失败 | 记录证据并失败,不能伪装成输入错误 |
| 取消/总时限耗尽 | 用户停止、上游 deadline 到期 | 尽快传播并清理资源 |
异常类应表达调用者可以采取的动作,而不只是底层库名:
class TaskError(Exception):
"""任务处理失败的基类。"""
class InvalidTask(TaskError):
"""调用者修正输入后才可能成功。"""
class TemporaryDependencyFailure(TaskError):
"""外部依赖短暂失败,可能允许重试。"""转换异常时保留因果链:
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise TaskError(f"无法读取输入文件: {path.name}") from excraise ... from exc 让日志保留根因;面向用户的消息仍可隐藏本机绝对路径或敏感响应。
try 的范围要小
只包住你预期失败的语句。范围过大会把自身 bug 误判成可恢复错误。
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise InvalidTask("输入文件不存在") from exc
else:
return parse_document(raw) # 解析缺陷不会被错当成文件不存在except:只捕获能解释或恢复的具体异常;else:只在没有异常时执行;finally:无论成功失败都必须做的收尾;- 上下文管理器
with/async with:优先用于文件、锁、连接和临时资源。
不要使用 except Exception: pass。如果顶层为了生成统一错误响应而捕获宽异常,也必须记录根因、返回失败状态并让监控可见。
超时是预算,不是一串独立数字
连接超时、单次请求超时和整个任务的总 deadline 不同。如果每次重试都重新获得完整超时,三次“10 秒请求”加退避可能让用户等待远超预期。
工程上先定义总预算,再为每一步分配剩余时间:
任务总时限 20 s
├─ 输入校验 ≤ 1 s
├─ 外部调用(含所有重试)≤ 15 s
└─ 解析、写入与收尾预留 4 s标准库没有一个能强制中断任意同步函数的通用安全开关。真正的网络、数据库或 SDK 调用必须使用其明确的超时参数;对异步代码使用 asyncio.timeout()。不要以为 Future.result(timeout=...) 会自动终止后台正在执行的阻塞工作。
何时允许重试
同时满足以下条件才重试:
- 失败被明确归类为瞬时失败;
- 操作本身是幂等的,或使用幂等键避免重复副作用;
- 尚未超过最大尝试次数和总 deadline;
- 上游没有取消;
- 每次失败都可观察,并最终返回最后根因。
读取通常比“扣款、发邮件、创建工单”更容易安全重试。后者即使客户端看到超时,服务端也可能已经成功;没有幂等键时盲目重试会重复执行。
下面是可测试的同步重试骨架。sleep 被注入,测试时无需真的等待:
from collections.abc import Callable
from typing import TypeVar
T = TypeVar("T")
def retry_transient(
operation: Callable[[], T],
*,
attempts: int,
sleep: Callable[[float], None],
base_delay: float = 0.2,
) -> T:
if attempts < 1:
raise ValueError("attempts 必须至少为 1")
for attempt in range(1, attempts + 1):
try:
return operation()
except TemporaryDependencyFailure:
if attempt == attempts:
raise
sleep(base_delay * (2 ** (attempt - 1)))
raise AssertionError("循环应当已经返回或抛出")生产系统通常还加入随机抖动(jitter),避免大量客户端同时重试。随机源也应可注入,以便测试确定性。HTTP 的具体状态码、Retry-After、认证和幂等语义在 API 中学习;本节只负责 Python 控制流。
这个骨架没有实现总 deadline
它只演示“有限次数 + 可测试退避”。真实外部调用还要把剩余时间传给底层超时参数,并在每次调用和休眠前用单调时钟检查总 deadline;同步函数若不支持中断,外层计时不能强制停止它。取消传播也必须由实际同步/异步运行模型单独设计。
收尾与取消
- 文件优先
with path.open(...) as file:; - 临时目录使用
tempfile.TemporaryDirectory(); - 先写临时文件、校验成功后再原子替换,避免半成品;
- 不在
finally中抛出新的无关异常覆盖原始失败; - 异步任务收到
CancelledError时先做必要清理,再重新抛出,不能吞掉取消。
练习
- 为上面的
retry_transient写测试:前两次失败、第三次成功;断言调用次数和退避序列为0.2, 0.4。 - 再写“永久错误不重试”和“尝试次数耗尽”的测试。
- 选择一个有副作用的操作,说明它需要什么幂等键或补偿机制后才能重试。
- 为一个 10 秒总预算画出单次调用、退避和收尾的最坏时间线。
自测
- 能解释输入错误、瞬时失败、永久失败和程序缺陷为什么不能统一重试。
- 能说明单次超时与任务总 deadline 的区别。
- 能证明某个操作是否幂等,而不是只说“应该没问题”。
- 能保留异常因果链,同时避免向调用方泄露敏感细节。
- 能指出资源在成功、异常和取消三条路径上的关闭位置。
相关概念与下一步
- 前置:文件、JSON 与输入校验。
- 下一节:配置、日志与敏感信息 把失败变成可诊断证据。
- 系统级恢复策略见 运行监控 与 工作流自动化。
参考资料
获取日期:2026-07-14。