调用大模型 API 时,最危险的处理方式不是完全不重试,而是遇到任何失败都立刻重试。429 可能意味着请求频率或额度受限;超时可能发生在服务端已经生成结果之后;500、502、503 和 504 的含义也不完全相同。没有分类的重试会放大流量、重复计费,还可能让原本短暂的故障变成重试风暴。
本文只解决一个问题:大模型 API 429 重试以及相邻的超时、5xx 应该怎样判断和执行。多模型的选择、轮询与故障切换是另一个层次,可另见Node.js 多模型轮询与故障切换;统一配置多个 OpenAI 兼容模型则参考OpenAI 兼容 API 多模型统一配置。
一、先判断失败发生在哪一层
排查时不要只记一行“调用失败”。至少区分四层:
- 请求构造层:URL、鉴权、模型名、JSON 或参数不合法;
- 网络传输层:DNS、连接建立、TLS、连接重置或客户端超时;
- HTTP 协议层:服务返回 4xx 或 5xx,并可能附带
Retry-After; - 业务响应层:HTTP 200,但响应不是预期 JSON、流式内容中断或业务字段表示失败。
这四层需要不同证据。网络错误没有 HTTP 状态码;HTTP 错误应保存状态码、响应头和经过清洗的错误类型;业务解析错误则要记录响应格式、解析阶段和请求关联 ID。把它们都压成字符串 AI error,后续无法判断该修配置还是重试。
二、重试决策表
下面是默认决策起点,不替代具体供应商文档:
| 现象 | 常见含义 | 默认是否重试 | 建议动作 |
|---|---|---|---|
| 400 / 422 | 参数、JSON 或上下文不合法 | 否 | 修正请求;不要用重试掩盖校验错误 |
| 401 | Key 无效或鉴权格式错误 | 否 | 检查密钥、请求头和部署环境 |
| 403 | 权限、区域或模型访问受限 | 否 | 核对账号权限和模型授权 |
| 404 | URL、部署名或模型名错误 | 通常否 | 核对 endpoint 与 model;仅配置刚发布且文档明确时再判断 |
| 408 | 对方认为请求超时 | 有条件 | 确认请求可安全重复,再退避重试 |
| 409 | 状态冲突 | 依接口而定 | 只有官方说明可重试时才重试 |
| 429 | 频率、并发、令牌或额度受限 | 有条件 | 优先遵循 Retry-After,同时检查配额是否已耗尽 |
| 500 | 服务内部错误 | 通常可有限重试 | 退避并设置总时间预算 |
| 502 / 503 / 504 | 网关或服务暂时不可用 | 通常可有限重试 | 退避加 jitter,持续失败时停止并告警 |
| DNS / TLS / 连接拒绝 | 本地网络、域名、证书或服务不可达 | 有条件 | 先分类;配置与证书错误不应盲目重试 |
| 客户端超时 / 连接重置 | 结果状态不确定 | 有条件 | 按幂等边界决定,记录“结果未知” |
| 响应解析失败 | 响应截断、格式变化或非 JSON | 有条件 | 保存脱敏元数据;先判断是否为临时网关响应 |
“可重试”不等于“无限重试”。每次调用都需要最大尝试次数、单次超时和总时间预算。队列任务还要设置任务级截止时间,否则应用层和队列层叠加重试后,实际尝试次数会成倍增加。
三、429:先看 Retry-After,再看配额类型
HTTP 标准允许服务器通过 Retry-After 告诉客户端多久之后再尝试。它可能是秒数,也可能是 HTTP 日期:
function parseRetryAfter(value, now = Date.now()) {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.ceil(seconds * 1000);
}
const date = Date.parse(value);
if (Number.isFinite(date)) {
return Math.max(0, date - now);
}
return null;
}
收到 429 后先读取它,不要无条件固定等待一秒。但 Retry-After 也不是重试许可:如果错误体明确表示余额、月度额度、账号权限或硬配额耗尽,等待几秒通常没有意义,应停止任务并通知管理员。
同一供应商还可能分别限制每分钟请求数、每分钟令牌数和并发数。仅降低请求次数不一定能解决令牌限流;请求很长时,应同时检查输入长度、输出上限、并发和批处理策略。错误体结构因供应商而异,解析器应允许未知字段,不能把某一家供应商的 code 写死成通用标准。
四、指数退避必须加入随机抖动
多个任务在同一时间收到 429 或 503,如果都按 1、2、4、8 秒重试,它们仍会在相同时间再次冲击服务。随机抖动(jitter)的作用是把重试时刻打散。
下面使用 full jitter:先算指数上限,再在零到上限之间随机取值。若服务器返回有效 Retry-After,则以它为最低等待基准,同时仍限制最大等待:
function backoffDelay(attempt, { baseMs = 500, capMs = 20_000 } = {}) {
const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
return Math.floor(Math.random() * (ceiling + 1));
}
function chooseDelay({ attempt, retryAfterMs, capMs = 20_000 }) {
const jitterMs = backoffDelay(attempt, { capMs });
if (retryAfterMs == null) return jitterMs;
return Math.min(capMs, Math.max(retryAfterMs, jitterMs));
}
如果供应商要求的 Retry-After 超过客户端允许等待的总预算,不要偷偷缩短后继续撞限流;应结束本次同步请求,或把任务延后到队列中。同步页面请求尤其不能为了重试让用户连接长时间悬挂。
五、Node.js 可审计的重试实现
以下示例把“是否可重试”和“等待多久”分开。它不包含真实 API Key,也不绑定某个模型供应商:
import { setTimeout as sleep } from 'node:timers/promises';
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
function isRetryableNetworkError(error) {
return ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(error?.cause?.code);
}
function sanitizeErrorBody(text) {
return String(text)
.replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [REDACTED]')
.slice(0, 1000);
}
export async function requestWithRetry(url, requestInit, options = {}) {
const {
maxAttempts = 4,
attemptTimeoutMs = 30_000,
totalBudgetMs = 70_000,
onAttempt = () => {}
} = options;
const startedAt = Date.now();
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const remainingMs = totalBudgetMs - (Date.now() - startedAt);
if (remainingMs <= 0) throw new Error('retry budget exhausted', { cause: lastError });
const timeoutMs = Math.min(attemptTimeoutMs, remainingMs);
try {
const response = await fetch(url, {
...requestInit,
signal: AbortSignal.timeout(timeoutMs)
});
if (response.ok) return response;
const body = sanitizeErrorBody(await response.text());
const retryable = RETRYABLE_STATUS.has(response.status);
onAttempt({ attempt, status: response.status, retryable, errorBody: body });
if (!retryable || attempt === maxAttempts - 1) {
throw new Error(`model API returned ${response.status}`);
}
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
const delayMs = chooseDelay({ attempt, retryAfterMs });
if (Date.now() - startedAt + delayMs >= totalBudgetMs) {
throw new Error('not enough retry budget');
}
await sleep(delayMs);
} catch (error) {
lastError = error;
// 主动超时、网络错误与代码异常必须区分;未知异常不默认重试。
const retryable = error?.name === 'TimeoutError' || isRetryableNetworkError(error);
onAttempt({ attempt, retryable, errorName: error?.name, errorCode: error?.cause?.code });
if (!retryable || attempt === maxAttempts - 1) throw error;
const delayMs = backoffDelay(attempt);
if (Date.now() - startedAt + delayMs >= totalBudgetMs) throw error;
await sleep(delayMs);
}
}
throw lastError;
}
生产实现还需处理一个细节:示例在 HTTP 分支抛出的普通错误会进入 catch,因而不会再次重试,这是为了避免一段代码对同一个响应重复做重试决策。更完整的封装可以定义 HttpError,在一个统一出口处理;关键是每次失败只能由一层决定是否重试。
六、超时不是“服务端什么都没做”
客户端在 30 秒终止请求,只能说明客户端没有在期限内拿到完整响应,不能证明服务端没有收到请求或没有完成生成。如果接口会创建批任务、写文件、扣减额度或触发后续动作,直接重试可能产生重复副作用。
因此需要明确幂等边界:
- 纯文本生成通常可以再次请求,但可能重复计费并得到不同结果;
- 创建批任务、上传文件或触发发布前,应优先使用供应商支持的幂等键;
- 若供应商不支持幂等键,在本地为业务操作生成稳定
operationId,保存请求状态和响应关联 ID; - 超时后把状态标为
unknown,先查询已有任务或人工核对,不要直接标记为failed; - 流式响应已经收到部分内容时,应明确选择丢弃重试、续传或保留部分结果,不能默默拼接两次生成。
幂等键是否被支持、有效期多长,必须以目标接口官方文档为准。不要自行添加一个请求头后就假定服务器会去重。
七、哪些错误不应该重试
以下情况应尽快失败并暴露配置问题:
- API Key 缺失、失效或没有权限;
- base URL、路径、模型名或部署名拼写错误;
- 请求体不符合接口约束;
- 输入超过上下文限制,或输出上限不合法;
- 内容安全策略明确拒绝请求;
- 账户余额、硬配额或合同额度耗尽;
- TLS 证书持续无效;
- 程序自己的 JSON 序列化、字段访问或类型错误。
特别注意 404:在普通 HTTP 语义中它通常是资源不存在,而不是临时故障。把 404 纳入通用重试列表,往往只会把模型名或 URL 配错的问题重复执行多次。
八、日志要能排查,也要脱敏
建议为每次逻辑调用生成 requestId,每次尝试再记录 attempt。可以记录:供应商配置 ID、模型名、HTTP 状态、供应商请求 ID、错误类型、等待时间、耗时、输入字符数或令牌估算、是否最终成功。
不要记录:
- API Key、Authorization 和 Cookie;
- 完整请求头;
- 未经处理的提示词、用户正文和模型响应;
- 错误体中可能回显的密钥、邮箱或个人数据;
- 仅靠 URL 查询参数传递的敏感信息。
推荐结构化日志:
logger.warn({
event: 'model_api_retry',
requestId,
providerConfigId: model.id,
model: model.name,
attempt,
status,
retryAfterMs,
nextDelayMs,
elapsedMs
});
后台展示“当前使用哪个模型总结”时,应读取任务的执行快照,而不是读取后来可能已修改的当前配置。关于总结任务自身的质量门禁,可继续阅读AI 总结质量评估与回归测试。
九、按顺序排查,避免一上来改重试次数
遇到故障时可以按下面顺序:
- 用请求 ID 定位一次逻辑调用及其全部尝试;
- 判断是请求构造、网络、HTTP 还是业务解析错误;
- 对 4xx 先核对鉴权、URL、模型名、参数和硬配额;
- 对 429 检查
Retry-After、错误类型、并发、请求数与令牌量; - 对超时确认超时发生阶段,并判断结果是否可能已经产生;
- 对 5xx 查看是否集中于某个区域、模型或时间段;
- 检查应用层、SDK、反向代理和任务队列是否同时重试;
- 在受控环境模拟 429、503、慢响应和连接重置,验证等待、截止时间与日志;
- 连续失败达到预算后停止,进入延迟队列、人工审核或告警。
不要只调大超时。更大的超时可能减少客户端中断,也可能让工作进程被慢请求占满。应同时观察并发、队列长度、端到端截止时间和上游服务响应。
十、上线前最小检查清单
- 400、401、403、404 默认不重试;
- 429 能解析秒数与日期格式的
Retry-After; - 无
Retry-After时使用指数退避和 jitter; - 单次超时、最大尝试次数与总预算同时存在;
- 应用、SDK、网关和队列只有明确的一层负责主要重试;
- 非幂等操作有幂等键或本地操作记录;
- 超时状态不会被错误地当成“服务端未执行”;
- 日志不包含 Key、Authorization、原始提示词和敏感正文;
- 受控测试覆盖 429、503、慢响应、连接重置与不可重试 4xx;
- 最终失败会保留可定位的错误类型和请求关联 ID。
重试机制的目标不是把所有错误隐藏掉,而是在短暂故障时有节制地恢复,在永久错误时尽快停止,并为每一次决策留下足够且安全的证据。先完成错误分类、幂等和可观测性,再讨论把失败请求切到哪个模型,系统才不会把“故障切换”变成更难追踪的重复调用。