侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

fetch 超时之后请求有没有真的被取消

2026-9-15 / 0 评论 / 12 阅读

fetch 超时之后请求有没有真的被取消

同一个 800ms 才吐数据的接口,两种超时写法各打一次。判定地点放在服务端:/slow-1AbortSignal.timeout(150)/slow-3Promise.race([fetch, sleep(150)])req.abortedres.close 都带时间戳。下面是原始日志。

      7ms  request 到达     /slow-1
    158ms  req.aborted      /slow-1  客户端断开连接
    158ms  res.close        /slow-1  客户端提前走了
    211ms  request 到达     /slow-3
   1010ms  res.close        /slow-3  服务端写完了

一、时间线对照

观测点 AbortSignal.timeout(150)(/slow-1) Promise.race([fetch, sleep(150)])(/slow-3)
前端何时拿到结果 154ms 抛 TimeoutError 150ms 走 catch,请求没有停下来
请求到达服务端 7ms 211ms
服务端收到断开 158ms req.aborted,同一时刻 res.close 没有任何断开事件
800ms 的响应体 没有写出去 1010ms 按原计划写完
服务端对这次的判断 客户端提前走了 服务端写完了

分水岭在第三行。timeout 那次的连接在 158ms 真的断了,服务端记着「客户端提前走了」;race 那次一直跑到 1010ms,服务端按原计划把 800ms 的响应写完,全程不知道前端早就放弃了。

前端抛错的 154ms、服务端记下的 158ms,跟设定的 150ms 之间各有几毫秒偏差,来自事件循环和网络往返,别拿它当超时不准的证据。

二、Promise.race 留下的代价

它判断超时的时间没错,谁先结束就用谁的结果。代价在这套写法什么都没取消。

代价 实测与推断
连接 请求挂在连接上,服务端还在算
并发额度 浏览器对同一个域名的并发连接有上限,慢接口一多,后面的请求就得在队列里等这些假超时请求腾位置
上游负载 外层再套一个重试,服务端会同时收到好几份一样的活,日志里看着像用户在狂点按钮

后两行是从前面那张时间线推的,没有单独拉数据。

三、错误对象对照

两种写法抛出的东西,原始输出:

{"constructor":"DOMException","name":"TimeoutError","isDOMException":true,"isError":true,"message":"The operation was aborted due to timeout"}
{"constructor":"DOMException","name":"AbortError","isDOMException":true,"isError":true,"message":"This operation was aborted"}
触发方式 constructor name isDOMException isError
AbortSignal.timeout 到期 DOMException TimeoutError true true
AbortController.abort() 不带 reason DOMException AbortError true true
abort(new Error("用户点了取消按钮")) Error Error false 没打印

前两行都是 DOMException,e instanceof Error 也是 true,按名字分支就够用了。捕获里原来写的是 if (e instanceof TypeError),一条都没进去。第三行是例外:给 abort() 传了 reason,抛出来的就是那个对象本身。

const ac = new AbortController();
setTimeout(() => ac.abort(new Error("用户点了取消按钮")), 60);

实测抛出的 e.name"Error"e instanceof DOMException 是 false。想按名字判断,就不要传自定义 reason,或者自己包一层。

四、超时管到响应体读完为止

服务端 50ms 就把响应头发出来了,body 拖到 700ms 才写完,超时设 300ms:

耗时 58ms 拿到响应头 status=200,接着读 body…
读 body 抛错(耗时 304ms){"name":"TimeoutError"}

fetch() resolve 只代表响应头到了,res.text() 还在同一个信号的控制之下。要卡住整个过程,超时时间得按整个 body 的传输时间给,不能按首字节到达来估。58ms 的首字节、304ms 的中断点跟 300ms 的预算摆在一起看,就能看出这份预算是耗在读 body 的中途的。

五、四个边界的实测数字

边界 写法 实测结果
重试复用同一个信号 AbortSignal.timeout(200) 建在重试循环外 第一次 201ms 抛 TimeoutError;第二次 1ms 就抛错,名字还是 TimeoutError
信号的计时起点 建好信号后先 sleep(150) 再 fetch 请求只活了 48ms 就被砍,不是 200ms
已经 abort 过的信号 拿 abort 过的 signal 交给 fetch 0ms 抛 AbortError,服务端事件里没有这次请求的到达记录
挂着的超时定时器 AbortSignal.timeout(5000) 建完什么都不做 子进程 40ms 就退出,不用 clearTimeout

对应的原始片段:

重试别复用同一个超时信号

const sig = AbortSignal.timeout(200);
try {
  await fetch(slowUrl, { signal: sig }); // 201ms 后抛 TimeoutError
} catch (e) {
  // 打算重试
}
await fetch(fastUrl, { signal: sig });   // 1ms 就抛错,名字还是 TimeoutError

信号只要触发过一次,之后永远是 aborted 状态。代码里常见把 AbortSignal.timeout(3000) 写在重试循环外面的写法,第二次请求直接失败,日志里看着像服务端挂了。每次请求新建一个就好。

计时从创建信号那一刻开始

const sig = AbortSignal.timeout(200);
await sleep(150);
await fetch(slowUrl, { signal: sig }); // 请求只活了 48ms 就被砍

实测就是 48ms,不是 200ms。把信号提前建好揣在配置对象里,等于偷走了请求自己的超时预算,前面多等一会儿就炸。

已经 abort 过的信号不会把请求发出去

拿一个 abort 过的 signal 交给 fetch,0ms 抛 AbortError,服务端事件里没有这次请求的到达记录。取消按钮点两下不会打出两个请求,这点挺省心。挂着的超时定时器也不拖住进程,AbortSignal.timeout(5000) 建完什么都不做,跑一个子进程验证,40ms 就退出了,不用为它 clearTimeout。写脚本和测试的时候这点很舒服。

六、封装的行为对照

const fetchText = async (url, { timeout = 8000, signal: userSignal } = {}) => {
  const sig = userSignal
    ? AbortSignal.any([userSignal, AbortSignal.timeout(timeout)])
    : AbortSignal.timeout(timeout);
  try {
    const res = await fetch(url, { signal: sig });
    return { ok: true, status: res.status, data: await res.text() };
  } catch (e) {
    if (e.name === "TimeoutError") return { ok: false, kind: "timeout", ms: timeout };
    if (e.name === "AbortError") return { ok: false, kind: "cancel" };
    return { ok: false, kind: "other", err: String(e) };
  }
};

拿它跑三种情况,真实输出:

超时那次:{"ok":false,"kind":"timeout","ms":200}(实际 202ms)
取消那次:{"ok":false,"kind":"cancel"}
成功那次:{"ok":true,"status":200,"data":"立刻返回"}
场景 返回 实测耗时
超时(timeout=200) {"ok":false,"kind":"timeout","ms":200} 202ms
手动取消 {"ok":false,"kind":"cancel"} 未记录
成功 {"ok":true,"status":200,"data":"立刻返回"} 未记录

AbortSignal.any 把用户信号和超时信号并起来之后,超时那一路触发的名字仍然是 TimeoutError,手动 abort() 不带 reason 时是 AbortError,两种情况在同一个封装里分得开。

七、不适合设总时长的场景

上传大文件和长轮询不适合给总时长设超时,整包超时会把传到一半的请求掐掉。现在的做法是上传不设 timeout,只留手动取消按钮。服务端这边能不能第一时间感知到断开,也取决于用的框架,这里验证的是 Node 的 http 模块。浏览器兼容性我没在真浏览器里跑过,AbortSignal.timeoutAbortSignal.any 出现的时间不一样,用之前查一下 caniuse。

判断超时有没有生效,看连接断没断,别只看 Promise 有没有 reject。服务端事件里记下来的时间戳,比前端的日志靠得住。

    🤞 分享