接口超时后,到底该不该重试?一次讲清幂等与结果核对
点击“创建订单”,转了几秒后提示超时,再点一次却出现两张订单。这类问题常被归到网络不稳定,真正需要处理的却是一个信息缺口:调用方没有拿到结果,服务端可能已经完成操作。
因此,接口超时后能否重试,要看服务端是否提供可靠的去重约定,以及能否查到原操作的结果。仅仅增加重试次数,可能把一次响应丢失变成重复下单、重复扣库存或重复发送通知。
先看一个具体过程:调用方提交订单,数据库在第 3 秒提交成功;响应经过网关时连接中断,调用方在第 5 秒超时。此时订单已经存在。反过来,请求也可能根本没有抵达服务端。两种情况在调用方眼里,都可能只剩下一个超时异常。中止本地等待,也不代表服务端事务已经回滚。
HTTP 幂等描述的是重复请求的预期效果。 RFC 9110 将 PUT、DELETE 和安全方法定义为幂等,但并不要求每次响应完全相同;POST 能否安全重试,需要另有明确保证。它也没有承诺业务操作在所有故障下“精确执行一次”。这是协议语义与业务实现的边界。 RFC 9110,9.2.2
例如,把资源设置为同一个值,重复执行仍能得到目标状态;而“给库存减一”每执行一次都会改变结果。把后者的 HTTP 方法改成 PUT,不会自动获得防重复能力。
幂等键应当标识一次业务意图。 用户确认一张订单时生成一个随机键,并将它与订单草稿、请求参数一起持久化。同一次提交的网络重试、进程重启后的恢复,都复用这个键。不要在每次调用 fetch 时重新生成,也不要只凭请求内容推导:购买相同商品的两笔独立订单,仍可能是两个合法意图。
键的有效范围也要明确。例如用“调用身份、操作类型、幂等键”组成唯一约束,既避免不同身份相互冲突,也避免同一键误用于其他操作。服务端同时保存规范化参数的摘要;相同键却改变金额、数量或目标对象,应返回参数冲突,而不是继续执行。
Stripe 的官方说明明确区分了网络错误与业务结果:连接恢复不能证明原请求失败;在允许的重试窗口内,应保留原键和参数。它也提示,部分服务端错误可能已经产生副作用,应先核对原结果。 Stripe:Advanced error handling
服务端的去重判断必须扛住并发和崩溃。 “先查缓存,没有就执行,最后写缓存”存在空隙:两个请求可能同时查到不存在,也可能业务完成后进程退出,导致去重记录没有写入。AWS 对幂等接口的讨论强调,记录令牌与相关变更需要满足原子性等事务属性。 AWS:Making retries safe with idempotent APIs
对于同一个数据库内的订单创建,可以在事务中写入带唯一约束的操作记录、创建订单、保存结果关联。并发请求抢占失败后,应读取已有记录:完成了就返回原结果,处理中就返回可查询的状态,不要再启动第二次执行。
如果操作还要调用外部系统,本地事务无法包住整个过程。可以将待发送事件与业务数据一起提交,再由后台投递;但投递仍可能重复,需要下游继续支持去重或按业务编号查询。只有一把分布式锁,却没有持久化结果、下游去重与核对机制,不能覆盖“外部已经成功、本地尚未记账”的故障窗口。
调用方可以把提交与结果查询放在一起处理。下面是流程示意,URL 和响应结构由接入方提供,不对应任何现成接口;省略了鉴权、响应校验和持久化实现。
type Operation = Readonly<{ key: string; body: string }>;
type Result = {
state: "processing" | "succeeded" | "failed" | "unknown";
resourceId?: string;
};
type Endpoints = {
submit: string;
result: (key: string) => string;
};
async function submitOrCheck(
operation: Operation,
endpoints: Endpoints,
): Promise<Result> {
let response: Response | undefined;
try {
response = await fetch(endpoints.submit, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": operation.key,
},
body: operation.body,
signal: AbortSignal.timeout(5000),
});
} catch {
// 超时或连接异常,转入查询;不要生成新键。
}
if (response) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
try { return await response.json() as Result; } catch { /* 查询 */ }
}
try {
const checked = await fetch(endpoints.result(operation.key), {
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (checked.ok) return await checked.json() as Result;
} catch { /* 查询失败仍保留不确定状态 */ }
return { state: "unknown" };
}
// operation 在首次提交前落盘;后续重试继续传入同一份记录。
// await submitOrCheck(savedOperation, configuredEndpoints);
这段代码没有把非 2xx 响应一律重试。调用方还要按接口约定区分参数错误、权限错误、限流和并发冲突;仅凭一个 409 或 500,无法推导所有业务结论。生产实现也应校验返回结构,不能把类型断言当成数据验证。
拿到查询结果后,处理路径应清楚:成功就使用原资源编号;处理中继续等待;明确失败则展示原因;查不到或查询本身失败,保留“结果未确认”。尤其是读副本延迟、查询记录尚未落盘时,“查不到”不能直接解释为“从未执行”。
恢复策略还需要三个边界。第一,重试使用指数退避并加入随机抖动,遵守服务端的等待提示,避免故障时集中施压。第二,限制次数和总时长;超过预算后交给持久化核对任务,不能永远卡住前台。第三,明确幂等记录的保留期;过期后旧键也可能失去保护,不能继续盲目重放。
排查时,日志至少串起业务操作编号、幂等键、每次尝试编号、提交时间、查询结果和最终资源编号。这样才能分清“没有发出去”“已经受理”“结果回传丢失”。对仍不确定的操作,保留原记录和后续核对入口,通常比立刻标失败、再创建一条新任务更可靠。
可靠的重试需要把身份、参数、执行状态和最终结果连起来。调用方负责复用同一个意图,服务端负责原子去重,双方通过结果查询收敛不确定状态;每个环节都有明确依据,重复操作才有机会被真正挡住。

接口超时并不等于业务失败。本文从重复下单场景出发,说明幂等键的生命周期、服务端原子去重、并发与外部副作用处理,并用 TypeScript 展示提交超时后的结果查询,给出避免重复执行的恢复边界。
浙公网安备 33010602011771号