
同一个 800ms 才吐数据的接口,两种超时写法各打一次。判定地点放在服务端:/slow-1 走 AbortSignal.timeout(150),/slow-3 走 Promise.race([fetch, sleep(150)]),req.aborted 和 res.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.timeout 和 AbortSignal.any 出现的时间不一样,用之前查一下 caniuse。
判断超时有没有生效,看连接断没断,别只看 Promise 有没有 reject。服务端事件里记下来的时间戳,比前端的日志靠得住。