跳转到内容

在鸿渐里做正确的重试:错误码、退避与幂等键

调用大模型接口时,「失败」有很多种。把它们一律当成“再试一次”是最常见的生产事故来源: 轻则打垮自己的服务,重则在用户那一侧重复扣费。

这篇讲清三件事:什么错该重试、怎么退避、怎样保证重试不重复计费。

1. 先看错误码:谁该重试,谁不该

Section titled “1. 先看错误码:谁该重试,谁不该”
HTTP type 含义 该做什么
200 — 成功 —
400 upstream_client_error 你的请求被上游拒绝(参数/格式) 不要重试,改请求
400 context_length_exceeded 上下文超长 不要重试,截断或换长上下文模型
401 invalid_api_key Key 无效/被停用 不要重试,检查 Key
402 insufficient_balance 余额不足 不要重试,充值
429 rate_limited 触发限流(单 Key 并发上限或上游配额) 可以重试,必须退避
503 no_upstream 该模型暂时没有可用上游 可以重试,等 Retry-After
503 upstream_unavailable 上游不可达或重试耗尽 可以重试,等 Retry-After
502 sandbox_error 等 网关自身故障 不要立刻重试,先上报

判断依据不是“5xx 就重试”,而是响应体里的 retryable 字段: 可重试的错误会带 "retryable": true,并附 Retry-After 头(单位秒)。

// 503 的真实响应(实测)
HTTP/1.1 503 Service Unavailable
retry-after: 5
{
"error": {
"message": "upstream temporarily unavailable, please retry later",
"type": "upstream_unavailable",
"retryable": true,
"retryAfter": 5,
"detail": "connect ECONNREFUSED ..."
}
}

2. 重试必须带幂等键(否则会重复扣费)

Section titled “2. 重试必须带幂等键(否则会重复扣费)”

鸿渐对每次调用按 idempotency-key 去重:同一个键重复提交,只扣一次费。 不带这个头,网关会把每次请求当成新调用 —— 网络抖动导致的重试就会变成重复计费。

用什么当键:一次逻辑调用生成的 UUID,在重试过程中保持不变。

import uuid, time, requests
def chat(messages, model="deepseek-chat", max_retries=4):
idem = str(uuid.uuid4()) # 一次逻辑调用一个键,重试时不变
for attempt in range(max_retries + 1):
r = requests.post(
"https://hugen.tydash.net/v1/chat/completions",
headers={
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": idem,
"Content-Type": "application/json",
},
json={"model": model, "messages": messages, "max_tokens": 256},
timeout=60,
)
if r.status_code == 200:
return r.json()
body = r.json().get("error", {})
if not body.get("retryable"):
raise RuntimeError(f"不可重试:{r.status_code} {body.get('type')}")
# 优先用服务端给的 Retry-After,其次指数退避 + 抖动
wait = float(r.headers.get("Retry-After") or min(2 ** attempt, 20))
wait += (uuid.uuid4().int % 1000) / 1000.0 # 抖动,避免同时重试
time.sleep(wait)
raise RuntimeError("重试次数用尽")
// TypeScript 版本(Node 18+ / 浏览器 fetch)
async function chat(messages: unknown[], model = 'deepseek-chat', maxRetries = 4) {
const idem = crypto.randomUUID(); // 一次逻辑调用一个键
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch('https://hugen.tydash.net/v1/chat/completions', {
method: 'POST',
headers: { authorization: `Bearer ${API_KEY}`, 'idempotency-key': idem, 'content-type': 'application/json' },
body: JSON.stringify({ model, messages, max_tokens: 256 }),
});
if (res.ok) return res.json();
const err = (await res.json()).error ?? {};
if (!err.retryable) throw new Error(`不可重试:${res.status} ${err.type}`);
const retryAfter = Number(res.headers.get('retry-after') ?? Math.min(2 ** attempt, 20));
await new Promise((r) => setTimeout(r, retryAfter * 1000 + Math.random() * 500));
}
throw new Error('重试次数用尽');
}

固定 1 秒重试在限流场景下只会让情况更糟(所有客户端同时打回来)。建议:

  • 指数退避:2^attempt 秒,封顶 20 秒;
  • 加抖动:随机 0–1 秒,把重试打散;
  • 尊重 Retry-After:服务端既然给了建议等待时间,就按它来。

流式("stream": true)只在 HTTP 状态码返回之前可以安全重试。一旦开始收到 data: 帧, 再重试就会出现“前半段两次、后半段一次”的重复内容。

正确做法:

  1. 收到 5xx 且尚未收到任何帧 → 用同一个 Idempotency-Key 重试;
  2. 已经收到部分内容 → 不要重试这次请求;把已生成内容当作结果,或用新请求继续(新的幂等键)。
行为 实测结果
同一 Idempotency-Key 连发 3 次 3×200,用量记录 1 条,冻结分录 1 条,余额只扣一次
上游不可达 503 + retry-after: 5 + type: upstream_unavailable + detail
模型无可用上游 503 + retry-after: 5 + type: no_upstream
请求被上游拒绝(4xx) 原样 4xx,retryable: false

每次调用的响应头都会带 x-request-id(值就是你传的 Idempotency-Key,没传则是服务端生成的)。 拿它可以直接查这一次请求的去向:

Terminal window
curl "https://hugen.tydash.net/api/me/requests?requestId=<x-request-id>" \
-H "Authorization: Bearer <你的 TyDash 登录令牌>"

返回的是你自己组织的这条请求:模型、token 数、耗时、扣费金额、成功/失败, 以及投影后的上游信息(例如 {"providerLabel":"官方通道","groupLabel":"标准组"})—— 真实供应商名称不会对外暴露,这是我们的号池合规约定。

看不到别人的数据:接口只按你所属组织过滤。

可重试的错误看 retryable 字段,退避用指数 + 抖动 + 尊重 Retry-After, 重试一定带同一个 Idempotency-Key。

按这三条做,网络抖动不会变成重复扣费,限流也不会演变成雪崩。