demo 里一次调用要么成功要么失败,重跑一遍就是了;上了生产,同样的代码每天被调用几万次,失败就从偶发插曲变成日常配额——网络抖一下、限流撞一下、服务端晃一下,任何一个都能让无防护的进程半夜崩掉。生产环境的第一课不是"怎么调得更聪明",而是调用必失败,代码要在失败里活着。
本篇是《OpenAI Python SDK 从入门到实战》系列第 9 篇,对应第 1 篇路线图"工程落地"阶段的第二站。文中重试、异常与超时口径核实自官方仓库 README 与 developers.openai.com 文档(2026 年 10 月)。全文按"失败模式分类 → 内置重试 → 异常地图 → 超时配置 → 手写退避 → 幂等判断 → 批处理实战 → 总结"组织。
1、调用必失败:失败模式分类
把线上能见到的失败倒在桌上看只有四类,各自的处理动作完全不同。第 1 篇报错速查提过 401、429、404 三个常见码,这里把视野拉全,分类决策如下:
解读:顶层先分"带不带状态码"。不带状态码的是网络层失败——超时与连接错误,SDK 连 HTTP 响应都没拿到;带的按状态码三分:429 是"你太猛",退避后再试;5xx、408、409 是"对方抖",通常直接重试就有戏;其余 4xx 是"你错了",密钥失效、参数非法、模型名拼错,重试一万次也不会变对,只能修代码修配置。
分类定动作:限流要退避,网络与服务端要重试,请求错要修自己。还有一个容易忽略的变量——重试本身也有代价,第 6 节专门回答"什么时候不该重试"。
2、SDK 自带重试机制
好消息是决策树里"值得重试"的那几格,SDK 已经替你做了。官方口径:部分错误默认自动重试 2 次,带短指数退避。哪些失败参与自动重试、哪些被刻意放过,清单如下:
| 失败类型 | 代表状态码 | 自动重试 | 理由 |
|---|---|---|---|
| 连接错误 | 无状态码 | 重试 | 网络抖动常能自愈 |
| 请求超时 | 408 或读超时 | 重试 | 多为暂时性拥挤 |
| 限流 | 429 | 重试 | 退避后额度恢复 |
| 冲突 | 409 | 重试 | 并发冲突可自解 |
| 服务端错误 | 5xx | 重试 | 官方明确建议重试 |
| 请求错 | 400 401 404 422 | 不重试 | 重试不会变对 |
注意一个反直觉的点:超时默认也在重试之列——读超时的请求会被自动再发最多两次,APITimeoutError 是在重试耗尽后才抛到你面前。而 400、401、404、422 这类请求错一次都不重试,这正是想要的语义:密钥拼错重试无意义,还白白多耗几次尝试的时间。
重试次数用 max_retries 参数配置,客户端级与请求级各管一段:
from openai import OpenAI
client = OpenAI(max_retries=0) # 默认 2;置 0 完全关闭
safe = client.with_options(max_retries=5) # 这一路放宽到 5 次
resp = safe.responses.create(
model="gpt-6.1-terra",
input="用一句话解释指数退避。",
)
解读:构造参数 max_retries 改客户端默认值,全局生效;with_options 克隆出一个只改了重试次数的客户端,适合给个别脆弱链路单独放宽,其余请求维持默认。内置重试的局限也明显:退避基线与上限不可调,重试过程只有设 OPENAI_LOG=info 才能开出来一条日志,更谈不上熔断——这些缺口正是第 5 节手写退避的理由。完整说明见官方 Python API 参考与仓库 README。
3、异常类地图与精确捕获
SDK 把失败翻译成一套异常类,全部继承自 APIError,分两大支:网络层失败抛 APIConnectionError 家族,非 2xx 状态码抛 APIStatusError 家族。完整地图如下:
| 异常类 | 触发条件 | 继承自 |
|---|---|---|
APIError |
全部异常的公共基类 | Exception |
APIConnectionError |
连不上服务器 | APIError |
APITimeoutError |
请求超时 | APIConnectionError |
APIStatusError |
任意非 2xx 状态码 | APIError |
BadRequestError |
400 参数非法 | APIStatusError |
AuthenticationError |
401 密钥无效 | APIStatusError |
PermissionDeniedError |
403 无权限 | APIStatusError |
NotFoundError |
404 资源不存在 | APIStatusError |
UnprocessableEntityError |
422 参数校验失败 | APIStatusError |
RateLimitError |
429 触发限流 | APIStatusError |
InternalServerError |
5xx 服务端错误 | APIStatusError |
地图在手,捕获策略就清晰了:宽口径兜底、窄口径处置,且顺序必须子类在前。精确捕获的写法如下:
import logging
import openai
log = logging.getLogger(__name__)
try:
resp = client.responses.create(
model="gpt-6.1-terra", input=QUESTION)
except openai.RateLimitError as e: # 429,最先判
log.warning("限流 request_id=%s", e.request_id)
raise # 交给上层退避
except openai.AuthenticationError: # 401,重试无意义
raise SystemExit("密钥失效,先修 .env 再跑")
except openai.APIStatusError as e: # 其余 4xx 与 5xx
log.error("状态码 %s:%s", e.status_code, e)
raise
except openai.APITimeoutError: # 必须在下一句之前
raise
except openai.APIConnectionError as e: # 网络层兜底
log.error("连接失败:%r", e.__cause__)
raise
解读三条。其一,except 自上而下匹配,而 RateLimitError 是 APIStatusError 的子类、APITimeoutError 是 APIConnectionError 的子类,子类必须写在父类前面,否则永远轮不到。其二,状态码异常自带 status_code 与 request_id——后者来自响应头 x-request-id,写进日志后向官方报障一报一个准;连接类异常则可读 __cause__ 拿到底层 httpx 错误。其三,示例里全部原样上抛,真实项目按业务决定:交互链路转成用户可读的降级话术,跑批链路落进错误清单(第 7 节)。各状态码的官方处置建议另见错误码指南。
4、超时配置三件套
先记一个容易被默认值坑住的数字:SDK 默认超时是 10 分钟。这个默认值照顾的是长推理任务,代价是一个挂起的请求要拖满 600 秒才报 APITimeoutError——交互产品里用户等 10 秒就走了。而且超时不是一个数,是三件事:建立连接的 connect、等响应数据的 read、发送请求体的 write,再外加一个兜底默认值(连接池等待也归它管)。SDK 底层用 httpx,四段可以分别指定:
import httpx
from openai import OpenAI
client = OpenAI(timeout=httpx.Timeout(30.0, connect=5.0))
resp = client.responses.create(
model="gpt-6.1-terra", input=QUESTION)
# 长任务单独放宽,不动全局默认
long_run = client.with_options(timeout=180.0)
解读:Timeout 的第一个位置参数 30.0 是兜底值,套用到全部阶段;connect=5.0 单独把建连压到 5 秒——连不上就是连不上,等再久也没用,快速失败交给重试更划算;read 默认走兜底值,流式场景下它约束的是相邻两个事件之间的间隔,而非整场输出的总时长,这是第 3 篇流式篇的口径。配置入口同样是两档:构造参数改全局,with_options 按请求覆盖。
注意把超时与重试放在一起算总账:默认 600 秒超时乘上默认 2 次重试,一个真挂死的请求最坏能拖半小时。生产环境先改超时,再谈重试次数。
三件套的取值没有万能数,给一组工程起点:交互链路兜底 30 秒、建连 5 秒;批处理兜底 120 秒;长推理单独放宽。分段语义详见 httpx 超时文档。
5、手写指数退避
内置重试覆盖八成场景,剩下两成要自己写,判断标准是有没有这些额外诉求:退避基线要自己定——429 高峰期等 1 秒可能还是撞墙,想从更长的基线起步;每次失败要落日志、打指标、发告警;要做熔断——连续失败到阈值就停机,别拿流量去撞枪口。这些内置机制一概不给,退避函数只好自己来:
import random
import time
import openai
def call_with_backoff(prompt, attempts=5, base=1.0, cap=60.0):
"""指数退避:base 乘 2 的 n 次方,抖动后封顶 cap"""
for n in range(attempts):
try:
return client.responses.create(
model="gpt-6.1-terra", input=prompt)
except openai.RateLimitError:
wait = min(base * 2 ** n, cap)
wait *= 0.5 + random.random() # 半倍到一点五倍抖动
print(f"第 {n + 1} 次 429,{wait:.1f}s 后重试")
time.sleep(wait)
raise RuntimeError(f"重试 {attempts} 次后仍被限流")
解读四个细节。其一,2 ** n 让等待按 1、2、4、8、16 秒翻倍,cap 封顶,防止夜里没人值守时等到天荒地老。其二,抖动不是装饰——十个进程同时被 429,若都等整 8 秒,8 秒后一起再撞一次墙;乘上 0.5 到 1.5 的随机因子,重试就错峰了。其三,只捕获 RateLimitError:429 等一等真的会好,401 等一万年也不会,选择性捕获本身就是第 1 节那张决策树的代码化。其四,函数在耗尽后显式抛错,调用方拿到的是"尽力之后的失败",可以放心落盘告警。内置与手写不是二选一:让 SDK 兜网络抖动的短重试,手写函数管限流的长退避,两层各司其职。限流成因与配额管理可再读官方速率限制指南。
6、幂等与安全重试
重试前一秒的安检问题是:这个请求再来一遍会不会出事?答案取决于副作用,按三问走。
第一问,请求是否只读或纯生成——补全、分类、摘要、翻译都算。这类请求天然幂等,重试最坏的代价是重复扣一次 token 费:读超时的那次请求,服务端可能其实已经跑完并计了费,你的重试又算一遍钱。概率低、单价小,工程上普遍接受;第 8 篇的计费函数正好用来核对账单异常波动。
第二问,副作用是否发生在调用之后、由你的代码执行。第 5 篇工具调用的真相是:模型只输出"想调什么工具",真正执行工具的是你的代码——所以重试模型调用本身不会重复下单、重复发邮件;危险在外层,一个超时让你误判失败、把整段 Agent 循环从头再跑,工具就真的执行了两次。
第三问,副作用有没有幂等键。给每个动作挂业务唯一键——订单号、消息 id、已处理记录的 custom_id——重复执行会被键挡下。第 8 篇 Batch 的 custom_id 就是现成例子:失败行按 id 重新组批,已完成的行不会被碰。
三问下来结论收敛成一句:纯生成随便重试,副作用靠幂等键兜底。没有幂等键的写操作,宁可不重试、进错误清单人工处理,也别赌它是最后一次失败。
7、实战:稳健的批处理跑批器
把前六节拼成一个能过夜的跑批器。场景沿用第 8 篇的千条评论分类,这次走同步逐条,适用于量不大但要立刻出结果的场合。目标三条:单条失败不拖死全场、修不好的直接跳过、跳过了谁必须有据可查。完整实现如下:
import json
import random
import time
import openai
client = openai.OpenAI() # 内置 2 次重试兜住网络抖动
errors = [] # 错误清单
def run(tasks, model="gpt-6-luna"):
for cid, text in tasks:
for n in range(3): # 外层长退避三轮
try:
resp = client.responses.create(
model=model,
instructions="把评论分为好评、中评、差评,只输出标签",
input=text)
print(cid, resp.output_text)
break
except openai.RateLimitError:
time.sleep(min(2 ** n, 30) * (0.5 + random.random()))
except openai.APIStatusError as e:
errors.append({"id": cid, "code": e.status_code, "err": str(e)})
break # 请求错不重试,落盘跳过
except openai.APIConnectionError:
time.sleep(1) # 含超时与断连,短等再试
else:
errors.append({"id": cid, "code": "exhausted"})
with open("errors.jsonl", "w", encoding="utf-8") as f:
for e in errors:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
run([("req-1", "物流快包装好"), ("req-2", "等了十天,差评")])
解读:两层防线分工——SDK 内置的 2 次短重试接住网络抖动,外层三轮长退避专治限流。三路异常三种命运:429 退避重试;其余状态码立即落盘跳过;网络层短等后再试,APITimeoutError 是 APIConnectionError 的子类,自动落到这一路。for...else 的 else 分支在循环没被 break 时执行,恰好接住"三轮耗尽"的场景。清单里带了状态码与异常文本,要追责可再补 request_id。跑完之后 errors.jsonl 就是第 8 篇按 custom_id 重新组批的现成输入——失败不是终点,是一条待重放的工作队列。
8、总结
失败分四类:请求错修自己,限流要退避,网络与服务端错直接重试;SDK 默认自动重试 2 次,覆盖连接错误、408、409、429 与 5xx,超时也在重试之列,400 类请求错则一次不试;异常分两支,APIStatusError 带 status_code 与 request_id,捕获时子类在前;默认超时 10 分钟,生产环境先压到秒级再谈其他;手写退避补齐自定义基线、日志与熔断;重试前用幂等三问安检;跑批器两层防线加错误清单落盘,失败可重放。对于准备上生产的读者,建议先把超时从默认 10 分钟改下来、按异常地图补齐捕获、再给每条批处理链路配一张错误清单——这三件事做完,失败就只是日志里的一行,而不是半夜的报警电话。
留两道练习:
- 把
max_retries置 0,用一个错误密钥跑第 3 节的捕获代码,验证 401 不触发重试、异常类与状态码一一对应 - 给第 7 节的跑批器补上 usage 统计与 request_id 落盘,跑一批真实数据,用第 8 篇的计费函数对账

