
Promise.race 管的是结果
给请求加超时,常见写法是让请求和定时器一起交给 Promise.race。定时器先兑现,调用方就能尽快得到超时结果。但这只改变了等待方看到的结果,另一个 Promise 不会因此自动取消。
可以把它想成两张独立的回执:竞速只挑出先到的一张,未被挑中的工作仍按原来的规则继续。fetch 已经把请求交给网络栈后,外层组合 Promise 没有向它传递取消信号。即使业务层不再等响应,服务端也可能继续处理并返回数据,客户端则继续占用连接和相关资源。下游若还做写入或扣费一类动作,单纯放弃等待更不能当成这些动作已停止的证据。
这里容易把「调用方超时」误读成「请求超时」。前者描述等待策略,后者需要请求自身支持取消,并收到取消通知。Promise 的竞速组合没有通用的取消协议,也不知道参与竞速的 Promise 是网络请求、定时器还是普通计算。它只负责按约定结算组合结果。
这也影响资源管理。外层返回超时后,原 Promise 若随后 reject,而程序没有保留并处理它,可能留下未处理的拒绝;请求侧则可能仍持有连接,服务端也可能照常完成工作。竞速本身并不清理定时器,除非代码另行清除。超时包装可以负责报告「等了多久」,但停止任务必须由具体任务提供机制。两个职责写在一起时,看起来省了几行,实际上把谁负责收尾藏了起来。
信号要交到 fetch 手里
fetch 接受 AbortSignal。把同一个信号放进请求选项,再由 AbortController 发出 abort,fetch 才能收到取消意图。差别不在定时器本身,而在超时发生时有没有触达正在执行的操作。
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timer);
}
超时回调与网络结果可能在临界时刻交错,清理定时器可以避免请求结束后还留下一个无用计时器。若上层还要把超时映射成自己的错误类型,应检查失败来自 abort 还是服务端响应,不要把所有异常都说成超时。请求开始前就已触发的 signal 也会让 fetch 直接失败,因此复用控制器时要确认它仍处于可用状态;一次请求结束后,通常应为下一次请求新建控制器。我的取舍是直接把 signal 传给发请求的那一层,少包一层只会让取消责任变得含糊。
本机服务记录了什么
验证脚本启动一个只监听 127.0.0.1 的慢响应服务。先让 Promise.race 的定时器赢,再等待原始 fetch 完成;随后对另一条请求调用 abort,并观察服务端响应连接在写完之前关闭。脚本不访问外网,完成后会关闭监听端口。
| 写法 | 本机观察结果 |
|---|---|
Promise.race |
调用端先收到 timeout,服务端随后正常完成响应 |
AbortController |
fetch 拒绝为 AbortError,服务端在响应完成前看到连接关闭 |
运行命令:
node tools/verify-abortsignal-timeout.mjs
本机 Node 输出:
Promise.race timeout: fetch continued; server completed response
AbortController: fetch rejected with AbortError; server saw connection close before response
第一行不是仅凭调用方收到 timeout 作出的推断,脚本还等到了服务端响应完成。第二行同时检查了 fetch 的 AbortError 和服务端观察到的提前断连。它说明取消信号传到了这条本地请求;不代表所有服务器收到断连后都会撤销已经开始的业务工作,服务端自己的取消与回滚仍要单独设计。
所以,Promise.race 适合限制调用方等待多久,不适合单独承担网络取消。超时需要停止 fetch 时,把 AbortSignal 接到请求,并让下游明确处理取消;只想让界面或 agent 先返回时,也要接受底层工作可能继续的事实。