Node.js:用 timers/promises 写可取消的 sleep

53 次浏览2 条回复

有些重试脚本会在两次请求之间 await sleep(5000),但进程收到退出信号时还得硬等完,挺烦。Node 内置的 node:timers/promises 可以配 AbortController,让等待本身也能被取消。

环境:Node.js 20+,Linux/macOS/Windows,只用内置模块。保存为 cancel-sleep-demo.mjs:

import { setTimeout as sleep } from 'node:timers/promises';

const controller = new AbortController();

process.once('SIGINT', () => {
  console.log('\nabort sleep');
  controller.abort();
});

async function main() {
  try {
    console.log('wait 10s, press Ctrl+C to cancel');
    await sleep(10_000, undefined, { signal: controller.signal });
    console.log('finished waiting');
  } catch (error) {
    if (error?.name === 'AbortError') {
      console.log('cancelled cleanly');
      return;
    }
    throw error;
  }
}

await main();

跑一下:

node cancel-sleep-demo.mjs

小坑是 setTimeout 的第二个参数是 resolve 出来的值,不想传值也要占个 undefined,第三个参数才是 { signal }。写轮询、退避重试、优雅退出时很顺手。

这个写法挺适合放在重试循环里。还有个小细节:如果外层本来就有一个 AbortSignal,可以直接把同一个 signal 传给 sleep 和请求函数,这样退出时不会出现“请求停了但退避等待还挂着”的半截状态。

类似这样:

for (let attempt = 1; attempt <= 3; attempt++) {
  try {
    return await doRequest({ signal });
  } catch (error) {
    if (attempt === 3) throw error;
    await sleep(1000 * attempt, undefined, { signal });
  }
}

读起来也比自己包一层 promise + clearTimeout 清爽。

还可以在进入等待前补一行 signal?.throwIfAborted()。

不然有些封装里 signal 可能已经 abort 了,后面才走到 sleep(...),虽然 timers/promises 也会拒绝,但提前抛出来能让日志位置更接近真正的取消点。

async function backoff(ms, signal) {
  signal?.throwIfAborted();
  await sleep(ms, undefined, { signal });
}

写成小函数后,重试循环里就不太容易漏传 signal。