LOGO 首页 OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 技术文档 其他文档  
 
网站管理员

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

zhenglin
2026年11月6日 8:56 本文热度 141

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

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


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


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

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

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


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

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

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


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

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


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

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

try {

  await createOrder();

} catch {

  showToast('下单失败,请重新提交');

}

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


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

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

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

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


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

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

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


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

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

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

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

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


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

下面是配套示例使用的 request-json.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 请求头中:

const operation = {

  key: crypto.randomUUID(),

  payload: Object.freeze({ sku: 'book', quantity: 1 }),

  status: 'ready',

};

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

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. 操作记录保存多久,过期后如何处理重放。删除记录后再接收相同标识,有可能重新产生业务效果。


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

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

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

GET /api/order-operations/{operationId}

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

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

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

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

文案也可以更贴近状态:提交结果尚未确认,请先查询处理结果。


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


哪些请求适合自动重试?

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

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

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

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

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

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

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 或代码问题。


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

退避上限 = min(单次等待上限, 基础等待 × 2^(已经失败的次数 - 1))

随机退避 = [0, 退避上限) 内的随机时长

实际等待 = max(随机退避, 服务端要求的最小等待时间)

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


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


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


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


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

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

在示例目录执行:

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

本次验证的输出为:

test{ }

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


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


回到那句“请求超时,请重试”

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


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

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


阅读原文:点击这里​


该文章在 2026/11/6 8:56:40 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved  粤ICP备13012886号-2  粤公网安备44030602007207号