接口超时了,前端为什么不能直接重试?

订单提交后,按钮转了一会儿,页面弹出一句"请求超时,请重试"。

用户再点一次,依然超时。过了一会儿,订单列表里却出现了两笔订单。

前端明明收到了两次错误,为什么服务端执行了两次成功操作?

因为这里的"错误",首先描述的是客户端没有正常拿到结果。它没有证明订单创建失败,更没有证明服务端什么也没做。

上一篇讨论了页面离开后如何停止请求。这次继续往前走一步:如果一个请求已经发出,客户端停止等待后,还能不能再发一次?

这个问题需要一起考虑请求语义、业务状态和服务端契约,不能仅靠请求工具里的一个 retry: 3 解决。

一次超时,可能发生在订单成功之后

先看一个完全可能出现的时间顺序:

时间 客户端 服务端
0ms 发起创建订单请求 接收请求
150ms 仍在等待 订单已创建
500ms 等待超时,取消本地请求 已创建的订单仍然存在
约 1350ms 已经不再等待这个响应 尝试返回首次请求结果

服务端完成业务和客户端收到响应,是两件事。

响应还需要经过服务端输出、网关、网络和浏览器读取。任何一个环节延迟或中断,客户端都可能拿不到成功结果。

所以,超时后前端能够确定的是"这次没有按预期取得结果",业务结果可能已经成功,也可能尚在处理,或者请求尚未到达服务端。

这也是为什么下面这种处理值得警惕:

js 复制代码
try {
  await createOrder();
} catch {
  showToast('下单失败,请重新提交');
}

如果没有进一步判断,文案已经把"结果未知"解释成了"确认失败"。用户照着操作,就可能创建第二笔订单。

前端需要表达三类结果,而不只是成功或失败

对有业务副作用的操作,至少应该区分:

结果类别 需要什么证据 合适的界面行为
已确认成功 收到有效的业务成功结果,或查询到该操作成功 展示订单号等结果,避免引导重复创建
已确认拒绝或失败 接口契约明确表示操作未执行或已经终止 展示原因,按规则修改参数或重新操作
结果未知或尚在处理 超时、断连、无法解释的响应,或业务仍在处理中 保留操作标识,查询状态,允许受控恢复

HTTP 状态码能帮助判断,却不能独立解释全部业务状态。

例如,应用明确约定"参数校验返回 400,且校验发生在写入之前",可以据此判断本次参数未通过校验。不能因此推广成"所有 4xx、5xx 都说明业务没执行"。

网关返回 504 时,上游业务可能已经完成;HTTP 200 也可能只表示正常返回了一份业务失败或处理中的结果。响应 JSON 解析失败,同样不能证明此前没有发生写入。

前端应该根据响应状态、业务结果和接口契约共同判断。没有足够证据时,承认结果未知,比给出一个错误的失败结论更有用。

禁用按钮、防抖和取消请求,各自解决什么问题?

这些措施都可以使用,但它们的保证范围不同。

措施 可以减少什么 仍然解决不了什么
点击后禁用按钮 当前界面中连续点击 多标签页、页面刷新、客户端再次发起请求
防抖 短时间内密集触发事件 同一业务操作跨越较长时间的重复提交
客户端进行中锁 当前进程中重复启动相同调用 服务端重复执行、其他客户端的提交
AbortController 不再需要的本地请求或响应体读取 已经提交的业务结果不会因此自动回滚
服务端幂等机制 契约范围内重复请求产生重复业务效果 未被纳入约束的外部副作用、过期后重放等情况

一个细节是:Promise.race 可以让调用方先收到超时结果,但不会自动取消另一个 Promise 对应的请求。

如果确实需要停止客户端请求,应将取消信号传入 fetch。但即使取消生效,也不要把它当成服务端回滚接口。撤销订单、退款、取消任务,都需要各自明确的业务操作。

先把超时和 HTTP 错误处理清楚

下面是配套示例使用的 request-json.js。它只负责请求与错误分类,没有自动重试。

js 复制代码
export class RequestTimeoutError extends Error {
  constructor() {
    super('客户端等待超时,业务结果尚未确认');
    this.name = 'RequestTimeoutError';
  }
}

export class HttpError extends Error {
  constructor(response, data) {
    super(`HTTP ${response.status}`);
    this.name = 'HttpError';
    this.status = response.status;
    this.data = data;
    this.retryAfter = response.headers.get('retry-after');
  }
}

export async function requestJSON(url, {
  timeoutMs = 5000,
  signal,
  ...options
} = {}) {
  const controller = new AbortController();
  let timedOut = false;
  const onAbort = () => controller.abort(signal.reason);
  if (signal?.aborted) onAbort();
  else signal?.addEventListener('abort', onAbort, { once: true });

  const timer = setTimeout(() => {
    if (controller.signal.aborted) return;
    timedOut = true;
    controller.abort();
  }, timeoutMs);

  try {
    const response = await fetch(url, { ...options, signal: controller.signal });
    // 把响应体读取也放在超时范围内,而不只等待响应头。
    const text = await response.text();
    let data;
    try {
      data = JSON.parse(text);
    } catch {
      if (response.ok) throw new Error('响应体不是有效 JSON');
      data = null;
    }
    if (!response.ok) throw new HttpError(response, data);
    return data;
  } catch (error) {
    if (timedOut) throw new RequestTimeoutError();
    throw error;
  } finally {
    clearTimeout(timer);
    signal?.removeEventListener('abort', onAbort);
  }
}

这段代码做了几件事:

  • 使用自己的 RequestTimeoutError 标记本工具设置的等待超时,避免把所有取消都叫作超时。
  • 检查 response.ok。fetch 不会仅因为 HTTP 4xx 或 5xx 就自动抛出异常。
  • 将响应体读取也放进超时范围,收到响应头不等于拿到了完整结果。
  • 在 finally 中清理计时器和外部取消监听。

这里的计时器也不是严格的实时截止时钟,后台调度、主线程阻塞等都可能影响触发时机。它用于控制客户端等待,不用于证明服务端执行时长。

对于创建订单,RequestTimeoutError 只说明本地等待超时。对于 HttpError,还要看具体接口怎样定义错误;不能看到错误类型就统一生成一份新订单再试。

真正需要重发时,先确定"是不是同一次业务操作"

假设用户正在购买一本书。

第一次提交后超时,用户想确认或继续完成这次购买。这是同一次业务操作的恢复。

而用户已经成功买了一本,又决定再买一本,是一次新的业务操作。

如果系统不区分这两件事,服务端看到两份相同商品参数,也不知道是网络重发,还是用户确实打算买两次。简单按"相同参数在几秒内去重",也可能误伤正常购买。

一种常见方案,是给一次业务意图分配稳定的操作标识。配套示例将它放在 Idempotency-Key 请求头中:

js 复制代码
const operation = {
  key: crypto.randomUUID(),
  payload: Object.freeze({ sku: 'book', quantity: 1 }),
  status: 'ready',
};

标识在发起这次操作之前创建。后续查询、恢复和重发沿用它,而不是在每次网络尝试前重新生成。

js 复制代码
import { requestJSON } from './request-json.js';

async function submitOperation(operation) {
  operation.status = 'processing';
  try {
    const result = await requestJSON('/api/orders', {
      method: 'POST',
      timeoutMs: 500,
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': operation.key,
      },
      body: JSON.stringify(operation.payload),
    });

    if (!['processing', 'succeeded'].includes(result.status)) {
      throw new Error('接口返回了未识别的业务状态');
    }
    operation.status = result.status;
    return result;
  } catch (error) {
    // 此处统一保守地保留操作,具体拒绝原因可按接口契约进一步分类。
    operation.status = 'unknown';
    throw error;
  }
}

这段展示的是操作对象与请求的关系。实际界面还要限制并发提交、验证业务响应结构,并根据状态更新按钮与提示。配套页面采用单次进行中锁,状态未知时保留原标识。

有三个边界不能省略。

同一标识应绑定相同的业务参数。 示例的 payload 是固定的扁平对象;真实项目需要保存规范化后的参数快照,不能让重发时读到一份已经修改的购物车。浅层冻结也不能保护任意嵌套数据。

标识需要覆盖实际恢复窗口。 只存在组件变量里,页面刷新就可能丢失。本地示例明确保留了这个限制;生产项目通常需要结合服务端操作记录、已有业务单号或合适的客户端持久化恢复。不要在刷新后直接认为旧操作不存在。

标识不能代替权限校验。 服务端仍要确认当前用户是否有权提交、查询该操作。多个客户端是否属于同一业务意图,也需要业务层建立关联,不能指望各自生成的随机标识自动合并。

请求头名称和下文的响应行为,都是本文示例明确约定的接口协议。使用其他接口时应遵守它自己的文档;给一个普通 POST 加上这个头,并不会让服务端自动具备幂等能力。

服务端需要做什么,幂等才不是一个空请求头?

配套示例为"当前用户 + 操作标识"保存一份记录,并绑定规范化后的订单参数:

重复请求的情况 示例接口的行为
首次看到该标识 先记录处理中,再执行创建订单
同标识、同参数,仍在处理 返回 202 和 processing,不再次执行
同标识、同参数,已经成功 返回同一订单结果,不再次创建
同标识、不同参数 返回 409,要求检查操作与参数的对应关系
执行结果无法确认 保留未知记录,供后续确认,不默默重新执行

这里的幂等,关注的是相同操作重复执行后,预期业务效果与一次执行一致,并不要求每次 HTTP 状态、时间戳或日志都完全一样。

本地示例使用 Map,并在首次 await 前同步占位,可以避免同一进程内两个请求同时启动同一份工作。它只用于说明协议,不能直接充当生产幂等服务。

真实服务至少还需要考虑这些问题:

  1. 多个服务实例共享同一个幂等约束,通常通过数据库唯一约束、事务或具有相应原子语义的存储实现。
  2. 业务写入和成功结果记录如何保持一致,避免订单已创建,却没有可恢复的结果记录。
  3. 进程在处理中崩溃后如何恢复或核对,不能让一条 processing 记录永久卡住,也不能未核对就再次执行。
  4. 操作记录保存多久,过期后如何处理重放。删除记录后再接收相同标识,有可能重新产生业务效果。

如果还涉及支付、库存或第三方系统,单个数据库事务也未必覆盖所有效果,需要下游幂等、业务对账或补偿等配合。不要把这套机制描述成对任意分布式操作的"绝对只执行一次"。

超时后,界面应该怎样恢复?

示例提供一个结果查询接口:

http 复制代码
GET /api/order-operations/{operationId}

超时后保留操作标识,再查询结果:

查询结果 界面可以怎么做
succeeded 展示原订单号,进入结果页
processing 提示仍在处理,按约定间隔继续查询或等待用户再次查询
明确终止且未执行的业务状态 解释原因,按业务规则允许重新操作
暂未查到记录,或查询本身失败 继续保留原标识,提示结果待确认,避免直接换新标识提交

查询不到,不等于证明从未执行。 原请求可能仍在途中,查询可能经过延迟的副本,或者操作记录不在当前查询窗口内。除非契约提供了更强保证,404 只能说明这次查询没拿到记录。

如果服务端契约保证同标识重发安全,那么恢复时可以沿用原标识和原参数再次提交。是否自动这样做,应由业务策略决定,而不是由通用请求拦截器对所有 POST 统一执行。

文案也可以更贴近状态:

提交结果尚未确认,请先查询处理结果。

这比"下单失败,请重新下单"更准确。对于耗时任务,也可以在提交后返回操作标识和处理中状态,由客户端单独查询,减少长时间占用一次同步响应。

哪些请求适合自动重试?

不能只看 GET、POST 这两个名字。

HTTP 规范将安全方法,以及 PUT、DELETE 定义为幂等方法;但实际接口如果错误地用 GET 执行扣款,换成 GET 并不会让扣款自动安全。

反过来,POST 如果由应用契约提供了可靠的幂等语义,也可能允许受控重试。关键是实际操作的预期效果,以及服务端有没有兑现对应保证。

即使操作可重试,也不是遇到所有错误都立刻重试:

情况 更合适的处理
参数校验失败、权限不足 先修正参数或权限,原样重发通常无效
登录失效 按认证流程处理,避免把登录恢复和普通重试叠加失控
429,或契约认为暂时可恢复的 503 等 有限次数重试,遵守 Retry-After,并控制等待与请求预算
网络断连或超时 先判断该操作是否允许重复执行,再决定恢复方式
用户主动取消、页面离开 通常停止这次任务,不应在后台悄悄重新发起
CORS、响应解析或程序错误 先定位原因,不能都当成临时网络波动

下面用一个已确认可重复读取的状态查询,展示受控重试:

js 复制代码
import { requestJSON } from './request-json.js';
import { retryRead } from './retry-read.js';

const result = await retryRead(({ signal }) => requestJSON(
  `/api/order-operations/${encodeURIComponent(operation.key)}`,
  { timeoutMs: 1500, signal },
), {
  maxAttempts: 3,
  baseDelayMs: 300,
  maxDelayMs: 3000,
  signal: pageController.signal,
});

这里的 pageController 由调用页面持有,页面离开时调用 abort()。maxAttempts: 3 表示总共最多尝试三次,包含首次调用。

配套的 retry-read.js 只对本工具的超时错误,以及约定的 429、502、503、504 进行有限重试,并在等待期间响应取消。它没有把所有 fetch 的 TypeError 一律归为可重试网络错误,因为这个类型也可能来自 CORS 或代码问题。

等待时间使用指数退避加随机抖动:

text 复制代码
退避上限 = min(单次等待上限, 基础等待 × 2^(已经失败的次数 - 1))
随机退避 = [0, 退避上限) 内的随机时长
实际等待 = max(随机退避, 服务端要求的最小等待时间)

Retry-After 可能是等待秒数,也可能是 HTTP 日期;日期形式涉及客户端时钟偏差,生产实现需要考虑这一点。跨域请求若要让浏览器 JavaScript 读取该响应头,还需要服务端提供相应的 CORS 响应头暴露配置。

如果服务端要求的等待超过本例设定的等待上限,示例直接交回界面处理,不会把 30 秒截成 3 秒后提前请求。

这仍然只是一个基础读取重试器:单次请求超时、重试次数和每次等待上限,不等于整个操作的总截止时间。生产项目还需要按体验要求设置总预算,并防止请求库、业务层、网关各自重试造成次数放大。

另外,查询成功但返回 processing,并不是一次传输失败。后续多久查询一次属于业务轮询策略,不应混入错误重试计数。

把"可能重复"变成一个可以复现的实验

配套示例不依赖第三方包,使用 Node.js 20 或更新版本即可运行。

在示例目录执行:

bash 复制代码
npm run demo

程序会启动本地服务,客户端等待 500ms;服务端约 150ms 创建订单,再延迟 1200ms 返回首次响应。服务端故意不因客户端断连而撤销已经完成的操作。

本次验证的输出为:

text 复制代码
普通提交第 1 次:客户端超时
普通提交第 2 次:客户端超时
普通提交实际创建订单:2 笔
带操作标识首次提交:客户端超时,保留相同 key 和参数
相同 key 再次提交与查询得到相同订单:true
带操作标识实际创建订单:1 笔

也可以运行 npm start,在浏览器中操作本地页面,比较普通提交、同标识重发和结果查询。运行 npm test 可以检查并发占位、参数冲突、超时结果恢复、响应体读取超时和退避取消等情况。

示例里的订单只存在内存,重启就会清空,也没有登录、数据库事务或支付流程。浏览器里的操作标识同样未实现刷新恢复。它用于验证本文的边界,不是一份可以直接上线的下单系统。

回到那句"请求超时,请重试"

一个超时提示,实际承载着一个业务判断:再次点击,是继续完成原操作,还是创建一个新操作?

对于普通读取,有限重试往往可以改善体验。对于下单、退款和任务创建,则应先有稳定的操作身份、服务端幂等约束,以及结果确认方式。

前端在这里要做的,是让界面状态与证据一致:确认成功才展示成功,确认拒绝才解释失败,无法确认时保留恢复路径。

下一次给接口加自动重试前,可以先问一句:如果上一次其实已经成功,这一次再发,会发生什么?

相关推荐
toooooop85 小时前
CRMEB 故障排查笔记:安全网关只放行 GET/POST 怎么办
前端·javascript·vue.js·thinkphp6·crmeb
软件技术NINI5 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
vipxieliang5 小时前
JavaScript 模板字符串与标签模板:不只是拼接
前端·javascript
Dream-Y.ocean16 小时前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
Highcharts.js17 小时前
Highcharts Gantt使用符号的自定义数据标签开发甘特图示例
javascript·甘特图·highcharts·图表开发·实战代码·响应式图表
Yves..19 小时前
HTTP 协议学习笔记:从报文结构到抓包实践
网络协议·http·fiddler
沫璃染墨19 小时前
从零入门计算机网络系列(十二):HTTP长连接与会话管理详解——从Connection到Cookie与Session
linux·服务器·c++·tcp/ip·ubuntu·http·交互
kyriewen20 小时前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
工控丫丫20 小时前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库