openai.RateLimitError: Error code: 429、openai.InternalServerError: Error code: 502、httpx.ConnectError 这三类,退避之后再打一次,大概率能拿到正常结果。400、401、403、404、422 这五种,重试一万次还是同一个返回值,你多花的只有等待时间和调用次数。把这两堆错误分开,重试策略的全部价值就在这里。分不清就无脑重试,等于给账单加了一层随机扣费。
429 说的是打得太快,不是打错了。同一把 Key 在短时间内的并发冲上去,网关会直接拒绝,有时还带一个 Retry-After 头告诉你等多久。抓一次响应头,比自己猜数字准:
curl -i -s -o /dev/null -D - -X POST https://xyuapi.top/v1/chat/completions \
-H "Authorization: Bearer $XYU_KEY" -H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash-thinking","messages":[{"role":"user","content":"ping"}]}' \
| grep -i -E '^(HTTP/|retry-after|x-ratelimit)'
出现 Retry-After: 3 就等 3 秒再发;出现 x-ratelimit-remaining: 0,说明配额见底,这种情况该退避,也该顺手把并发调小一档。
500、502、503、504 出现在上游处理请求的过程中:网关拿不到后端响应、实例正在重启、长请求被中间层掐断。这类错误跟你发的参数没关系,换个时间点再发,命中的可能是另一台健康的机器。
openai.InternalServerError: Error code: 502 密集出现,通常是上游某条线路在抖动。退避重试的同时把时间点记下来,连续十分钟都这样,该去查线路,而不是改代码。
httpx.ConnectTimeout 是 TCP 还没握上手,httpx.ReadTimeout 是连上了但没等到响应,httpx.ReadError: Connection reset by peer 是连接被中途掐断。这三种的共同点是结果未知,既没成功也没明确失败。重试是拿回结果的常规手段。
重试会不会产生副作用,靠接口自身的语义兜底,这一点后面单独讲。
messages 里少了 role,max_tokens 传成了字符串Bearer 后面多了一个空格这些是确定性错误。参数没改,重试只是把同样的失败再演一遍,顺带多消耗一次额度。
| 错误类型 | 典型报错原文 | 是否重试 | 建议动作 |
|---|---|---|---|
| 429 限流 | openai.RateLimitError: Error code: 429 | 重试 | 读 Retry-After,退避加抖动 |
| 500 | openai.InternalServerError: Error code: 500 | 重试 | 指数退避,记录时间点 |
| 502 | openai.InternalServerError: Error code: 502 | 重试 | 同上,持续异常就查线路 |
| 503 | openai.InternalServerError: Error code: 503 | 重试 | 上游过载,退避等待 |
| 504 | openai.InternalServerError: Error code: 504 | 重试 | 处理超时被切断 |
| 连接超时 | httpx.ConnectTimeout | 重试 | 结果未知,退避重发 |
| 读超时 | httpx.ReadTimeout | 重试 | 缩短单次超时再重试 |
| 连接重置 | httpx.ReadError: Connection reset by peer | 重试 | 换连接重发,看总预算 |
| 400 参数错 | openai.BadRequestError: Error code: 400 | 不重试 | 直接失败,打印原文 |
| 401 Key 错 | openai.AuthenticationError: Error code: 401 | 不重试 | 检查 Key 和空格 |
| 403 无权限 | openai.PermissionDeniedError: Error code: 403 | 不重试 | 换模型或换 Key |
| 404 模型名错 | openai.NotFoundError: Error code: 404 | 不重试 | 用 /v1/models 核对 |
| 422 校验不过 | Error code: 422 | 不重试 | 对照参数表核对字段 |
表里最该盯的是 400 和 404:出现频率高,也最容易被无脑重试盖过去。参数写错本来就该当场炸出来,被四次重试一掩盖,你可能第二天才发现整批任务全军覆没。
固定间隔重试是省事的写法:每 3 秒打一次,打满 6 次。上游在过载,你的节奏跟它恢复的节奏对不上,越打越糟。指数退避让等待时间随失败次数翻倍,给上游腾出恢复窗口,也省下无效请求。
delay = min(cap, base * 2 ** attempt)
base 是起步等待秒数,cap 是等待上限,attempt 从 0 开始计数。取 base=1、cap=30:第 1 次失败等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒,第 4 次等 8 秒,第 5 次等 16 秒,第 6 次本该等 32 秒,被 cap 压到 30 秒,六轮累计 61 秒。同样六轮,固定 3 秒间隔只要 18 秒——多出来的几十秒不是浪费,是留给上游回血的时间。
一批任务同时被 429,如果都严格等 1、2、4 秒,它们会在同一毫秒集体回来,把刚松开的配额又一次打满,然后集体再吃一轮 429。假设 1000 个任务在 09:00:00 同时被限流,不带抖动的话,09:00:01 会有 1000 个请求同时到达,09:00:03 再来 1000 个,上游看到的瞬时并发跟上一轮被限流时一模一样。这个现象叫惊群。
random.uniform(0, delay) 就是完整抖动:等待时间取 0 到计算值之间的均匀随机数。同一批任务的等待量在首轮散成 0 到 1 秒,第二轮散成 0 到 2 秒,峰值从 1000 掉到几十,上游才真的有机会喘气。有个细节容易踩:随机数种子别在循环里反复初始化,否则整批任务算出来还是同一个值。
# retry_util.py —— 只依赖标准库,可直接复制
import functools
import logging
import random
import time
import httpx
import openai
log = logging.getLogger("xyu.retry")
# 只有这些异常值得重试,其余异常原样抛出
RETRYABLE = (
openai.RateLimitError, # 429
openai.InternalServerError, # 500 / 502 / 503 / 504
openai.APITimeoutError, # SDK 侧请求超时
openai.APIConnectionError, # 连接建立失败或被重置
httpx.ConnectTimeout,
httpx.ReadTimeout,
httpx.RemoteProtocolError, # 连接被中途掐断
)
class RetryGiveUp(Exception):
"""重试次数或总预算用尽,主动放弃。"""
def with_retry(max_attempts=5, base=1.0, cap=30.0, budget=120.0):
if max_attempts < 1:
raise ValueError("max_attempts 至少为 1")
def deco(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
started = time.monotonic()
for attempt in range(1, max_attempts + 1):
try:
return fn(*args, **kwargs)
except RETRYABLE as exc:
used = time.monotonic() - started
# 抖动:等待时间取 0 到退避值之间的随机数,打散惊群
raw = min(cap, base * (2 ** (attempt - 1)))
delay = random.uniform(0.0, raw)
if attempt >= max_attempts:
log.error("attempt=%d/%d 放弃(次数用尽) 已用=%.1fs %s: %s",
attempt, max_attempts, used, type(exc).__name__, exc)
raise RetryGiveUp("次数用尽 attempt=%d" % attempt) from exc
if used + delay >= budget:
log.error("attempt=%d/%d 放弃(预算不足) 已用=%.1fs 需等=%.2fs",
attempt, max_attempts, used, delay)
raise RetryGiveUp("预算用尽 已用=%.1fs" % used) from exc
log.warning("attempt=%d/%d 失败 %s: %s -> 等待 %.2fs",
attempt, max_attempts, type(exc).__name__, exc, delay)
time.sleep(delay)
raise RetryGiveUp("循环意外结束") # 逻辑兜底,正常不会走到
return wrapper
return deco
client = openai.OpenAI(
api_key="你的Key",
base_url="https://xyuapi.top/v1",
max_retries=0, # SDK 自带重试关掉,只保留下面这一处
timeout=60.0,
)
@with_retry(max_attempts=5, base=1.0, cap=30.0, budget=120.0)
def chat(prompt, model="deepseek-v4-flash-thinking"):
if not prompt or not prompt.strip(): # 边界:空提示词不发请求
raise ValueError("prompt 不能为空")
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=False,
)
choices = getattr(resp, "choices", None)
if not choices: # 边界:上游返回空 choices
raise RuntimeError("上游返回空 choices")
content = choices[0].message.content
return content or "" # 边界:内容为 None 时给空串
别写 except Exception。它会把参数错误、Key 错误、模型名错误一起吞掉并重试,把你最想看的那条报错埋进日志里。把可重试异常列成一个元组,白名单式捕获,其余异常照原样往上抛。
重试静默进行的话,你只能看到最终失败。日志里带上三样东西就够用:第几次尝试、异常类名、本次等待秒数。上面那段代码打出来是这样:
attempt=2/5 失败 RateLimitError: Error code: 429 -> 等待 1.37sattempt=3/5 失败 InternalServerError: Error code: 502 -> 等待 3.02sattempt=4/5 放弃(预算不足) 已用=118.4s 需等=8.6s生产环境翻车的常常不是重试本身,是边界。空提示词要先拦下来,别浪费一次请求;上游返回空 choices 要当成异常,否则后面取值直接崩;max_attempts 传成 0 会让函数一次都不执行,开头就校验掉。连接被重置这类异常,异常元组里把 SDK 和 httpx 两边的类型都列上,能少踩这个坑。
五次重试、单次超时 60 秒、退避上限 30 秒,最坏情况是 5 乘 60 秒等待加上退避的十几秒,接近 320 秒。一个任务占住 worker 五分钟,一批 200 条排下去,后面的全部积压,你看到的就是任务超时。重试次数管的是次数,管不了时间,所以 budget 参数要单独设。
单个请求的预算管不了排队时间,整批任务还得有一个总时限:
import logging
import time
log = logging.getLogger("xyu.batch")
def run_batch(prompts, model="deepseek-v4-flash-thinking", total_deadline=600.0):
"""整批任务的总时限:到点就停,剩余条目直接标记失败。"""
if not prompts: # 边界:空列表直接返回
return [], []
end_at = time.monotonic() + total_deadline # 单调时钟,不怕系统时间被校时
ok, failed = [], []
for idx, prompt in enumerate(prompts):
if time.monotonic() >= end_at:
rest = prompts[idx:]
log.error("批次总时限 %.0fs 用尽,剩余 %d 条标记失败", total_deadline, len(rest))
failed.extend((p, "batch_deadline_exceeded") for p in rest)
break
try:
ok.append((prompt, chat(prompt, model=model)))
except (RetryGiveUp, ValueError, RuntimeError) as exc:
failed.append((prompt, repr(exc)))
log.info("批次结束 成功=%d 失败=%d 耗时=%.1fs", len(ok), len(failed),
total_deadline - (end_at - time.monotonic()))
return ok, failed
算耗时用 time.monotonic(),不要用 time.time()。系统对时、容器迁移都可能让后者的值往回跳,一旦往回跳,预算会算成负数,重试要么永远不触发,要么一次都不等。
OpenAI(api_key=..., max_retries=2) 表示 SDK 自己会重试 2 次,单次调用最多发 3 个请求;外面再套一层 3 次重试,最坏是 3 乘 3 等于 9 个请求。SDK 内部重试你完全看不见,日志里只有一次调用记录,成本和耗时同时放大到九倍。
把 max_retries=0 写进客户端初始化,重试统一交给自己的装饰器,attempt 次数、等待秒数、最终结果全在一个地方,出问题只查一个文件。真要用内置重试也行,那就别在外面再包一层,两处只留一处。
限流让流量平稳,重试让失败的请求再来一次,两个动作方向相反。你刚把并发从 50 压到 20,重试一来,瞬时并发又回到 40,上游看到的就是一次没压住的反弹。加退避能缓解,但退避只推迟了冲击时间,没减少冲击量。
稳妥做法是重试前先过一次并发闸门:用信号量或线程池把同时在途的请求数卡在阈值以下,超出的重试请求在队列里等。闸门大小按你实跑出来的限流阈值定——被 429 之前那一档并发,往下留三成余量。怎么量阈值、怎么把闸门跟退避串起来,之前那篇讲并发控制的文章拆得比较细,可以对着看。
/v1/chat/completions 是一次纯推理请求:读输入、返回文本,不改服务端状态,不生成订单,不写业务数据。结果未知时重发同一个请求,不会出现两条记录,也不会把某个字段加两次。这一点决定了它天然适合重试。用同一条链路去触发写操作时,重试前要想清楚会不会重复执行。
按次计费的门槛在这:每一次成功返回都算一次调用,跟输入多长没关系,固定价一次。重试成功的那一次照样计费。所以重试是可用性工具,不是省钱工具。常见单价长这样:gemini-2.5-pro 0.031 元一次,deepseek-v4-flash-thinking 和 grok-4.1 0.05 元,claude-sonnet-4-5-thinking 0.09 元,claude-opus-4-5-thinking 0.12 元,claude-opus-4-6-thinking 0.25 元,gpt-5.5 0.2 元。另有按量计费与无限卡套餐可选,最低充值 7 元,支付宝和微信都能付,不需要海外信用卡。
在装饰器里加一个 CSV 落盘:一行一次尝试,字段固定成时间戳、任务 ID、模型名、第几次尝试、异常类名、状态码、等待秒数、最终结果。不用上监控系统,追加写文件就够,出了事按任务 ID 过滤。
重试率等于发生重试的请求数除以总请求数。稳定在 5% 以内属于正常抖动;超过 5%,说明你的并发已经踩在限流线附近,再加 retry 只会让重试率更高、成本更贵。这个时候的动作是把并发降一档、把批量任务错峰。加 retry 是把问题往后推,降并发才是解决它。
# pip install tenacity
import logging
import httpx
import openai
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
stop_after_delay,
wait_exponential_jitter,
before_sleep_log,
RetryError,
)
log = logging.getLogger("xyu.tenacity")
client = openai.OpenAI(api_key="你的Key", base_url="https://xyuai.top/v1", max_retries=0)
@retry(
retry=retry_if_exception_type(( # 只重试这些类型
openai.RateLimitError,
openai.InternalServerError,
openai.APITimeoutError,
openai.APIConnectionError,
httpx.ReadTimeout,
httpx.RemoteProtocolError,
)),
wait=wait_exponential_jitter(initial=1, max=30, jitter=1), # 指数退避 + 抖动
stop=stop_after_attempt(5) | stop_after_delay(120), # 次数或时长任一触顶就停
before_sleep=before_sleep_log(log, logging.WARNING), # 每次等待前打日志
reraise=True, # 最后一次抛原异常
)
def chat(prompt, model="deepseek-v4-flash-thinking"):
if not prompt or not prompt.strip():
raise ValueError("prompt 不能为空")
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=False,
)
if not resp.choices:
raise RuntimeError("上游返回空 choices")
return resp.choices[0].message.content or ""
try:
print(chat("用一句话解释指数退避"))
except RetryError as exc:
log.error("重试全部失败: %s", exc.last_attempt.exception())
stop_after_attempt 管次数,stop_after_delay 管总时长,两个用竖线连起来表示任一满足就停,这正是手写版本里 max_attempts 加 budget 的等价写法。reraise=True 别漏,默认它会抛一个包了一层的 RetryError,你真正关心的 429 原文被藏在 last_attempt 里。jitter=1 表示抖动上限 1 秒,配 initial=1、max=30 够用。
| 第几次失败 | 本次等待(base=1, cap=30) | 累计等待 | 不设 cap 时的累计 |
|---|---|---|---|
| 1 | 1 秒 | 1 秒 | 1 秒 |
| 2 | 2 秒 | 3 秒 | 3 秒 |
| 3 | 4 秒 | 7 秒 | 7 秒 |
| 4 | 8 秒 | 15 秒 | 15 秒 |
| 5 | 16 秒 | 31 秒 | 31 秒 |
| 6 | 30 秒 | 61 秒 | 63 秒 |
1 加 2 加 4 加 8 加 16 加 32 等于 63 秒,cap 把第 6 次压到 30 秒,累计变成 61 秒。看这张表就明白为什么超过 5 次基本没意义:第 6 次还要再等半分钟,换来的成功率提升只有零点几个百分点,任务占用的时间却翻了一倍。真需要更高成功率,方向是降并发、换线路、配备用入口。
| 重试率 | 额外请求数 | 额外支出 |
|---|---|---|
| 1% | 100 次 | 5 元 |
| 5% | 500 次 | 25 元 |
| 7.5% | 750 次 | 37.5 元 |
| 15% | 1500 次 | 75 元 |
| 30% | 3000 次 | 150 元 |
按 deepseek-v4-flash-thinking 每次 0.05 元算,一万次等于 500 元。重试率 15% 意味着多打 1500 次请求,一千五百乘以 0.05 等于 75 元,相当于预算多出 15%;把重试率压到 7.5%,额外支出从 75 元掉到 37.5 元。
没有退避、直接重打的那套代码,成功率经常掉到 80% 以下。一万条任务有 2000 条要补救,整批重跑一遍等于再花 500 元。加上退避和错误分类之后,成功率能稳在 99.5% 以上:一万条里剩 50 条失败,补救成本只有 50 乘 0.05 等于 2.5 元。
同样是重试,一个有界、带抖动、带总预算的实现,和一个 try 套 try 的无脑重试,成本差着一个数量级。重试率这个数字每周看一眼,涨过 5% 就去查并发和模型选型,比事后对账单便宜得多。