在鸿渐里做正确的重试:错误码、退避与幂等键
调用大模型接口时,「失败」有很多种。把它们一律当成“再试一次”是最常见的生产事故来源: 轻则打垮自己的服务,重则在用户那一侧重复扣费。
这篇讲清三件事:什么错该重试、怎么退避、怎样保证重试不重复计费。
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 Unavailableretry-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('重试次数用尽');}3. 退避别写成固定间隔
Section titled “3. 退避别写成固定间隔”固定 1 秒重试在限流场景下只会让情况更糟(所有客户端同时打回来)。建议:
- 指数退避:
2^attempt秒,封顶 20 秒; - 加抖动:随机 0–1 秒,把重试打散;
- 尊重
Retry-After:服务端既然给了建议等待时间,就按它来。
4. 流式调用的特殊情况
Section titled “4. 流式调用的特殊情况”流式("stream": true)只在 HTTP 状态码返回之前可以安全重试。一旦开始收到 data: 帧,
再重试就会出现“前半段两次、后半段一次”的重复内容。
正确做法:
- 收到 5xx 且尚未收到任何帧 → 用同一个
Idempotency-Key重试; - 已经收到部分内容 → 不要重试这次请求;把已生成内容当作结果,或用新请求继续(新的幂等键)。
5. 这些行为是验证过的
Section titled “5. 这些行为是验证过的”| 行为 | 实测结果 |
|---|---|
同一 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 |
6. 出问题怎么自查
Section titled “6. 出问题怎么自查”每次调用的响应头都会带 x-request-id(值就是你传的 Idempotency-Key,没传则是服务端生成的)。
拿它可以直接查这一次请求的去向:
curl "https://hugen.tydash.net/api/me/requests?requestId=<x-request-id>" \ -H "Authorization: Bearer <你的 TyDash 登录令牌>"返回的是你自己组织的这条请求:模型、token 数、耗时、扣费金额、成功/失败,
以及投影后的上游信息(例如 {"providerLabel":"官方通道","groupLabel":"标准组"})——
真实供应商名称不会对外暴露,这是我们的号池合规约定。
看不到别人的数据:接口只按你所属组织过滤。
7. 一句话总结
Section titled “7. 一句话总结”可重试的错误看
retryable字段,退避用指数 + 抖动 + 尊重Retry-After, 重试一定带同一个Idempotency-Key。
按这三条做,网络抖动不会变成重复扣费,限流也不会演变成雪崩。