/ utils / sleep.ts
sleep.ts
 1  /**
 2   * Abort-responsive sleep. Resolves after `ms` milliseconds, or immediately
 3   * when `signal` aborts (so backoff loops don't block shutdown).
 4   *
 5   * By default, abort resolves silently; the caller should check
 6   * `signal.aborted` after the await. Pass `throwOnAbort: true` to have
 7   * abort reject — useful when the sleep is deep inside a retry loop
 8   * and you want the rejection to bubble up and cancel the whole operation.
 9   *
10   * Pass `abortError` to customize the rejection error (implies
11   * `throwOnAbort: true`). Useful for retry loops that catch a specific
12   * error class (e.g. `APIUserAbortError`).
13   */
14  export function sleep(
15    ms: number,
16    signal?: AbortSignal,
17    opts?: { throwOnAbort?: boolean; abortError?: () => Error; unref?: boolean },
18  ): Promise<void> {
19    return new Promise((resolve, reject) => {
20      // Check aborted state BEFORE setting up the timer. If we defined
21      // onAbort first and called it synchronously here, it would reference
22      // `timer` while still in the Temporal Dead Zone.
23      if (signal?.aborted) {
24        if (opts?.throwOnAbort || opts?.abortError) {
25          void reject(opts.abortError?.() ?? new Error('aborted'))
26        } else {
27          void resolve()
28        }
29        return
30      }
31      const timer = setTimeout(
32        (signal, onAbort, resolve) => {
33          signal?.removeEventListener('abort', onAbort)
34          void resolve()
35        },
36        ms,
37        signal,
38        onAbort,
39        resolve,
40      )
41      function onAbort(): void {
42        clearTimeout(timer)
43        if (opts?.throwOnAbort || opts?.abortError) {
44          void reject(opts.abortError?.() ?? new Error('aborted'))
45        } else {
46          void resolve()
47        }
48      }
49      signal?.addEventListener('abort', onAbort, { once: true })
50      if (opts?.unref) {
51        timer.unref()
52      }
53    })
54  }
55  
56  function rejectWithTimeout(reject: (e: Error) => void, message: string): void {
57    reject(new Error(message))
58  }
59  
60  /**
61   * Race a promise against a timeout. Rejects with `Error(message)` if the
62   * promise doesn't settle within `ms`. The timeout timer is cleared when
63   * the promise settles (no dangling timer) and unref'd so it doesn't
64   * block process exit.
65   *
66   * Note: this doesn't cancel the underlying work — if the promise is
67   * backed by a runaway async operation, that keeps running. This just
68   * returns control to the caller.
69   */
70  export function withTimeout<T>(
71    promise: Promise<T>,
72    ms: number,
73    message: string,
74  ): Promise<T> {
75    let timer: ReturnType<typeof setTimeout> | undefined
76    const timeoutPromise = new Promise<never>((_, reject) => {
77      // eslint-disable-next-line no-restricted-syntax -- not a sleep: REJECTS after ms (timeout guard)
78      timer = setTimeout(rejectWithTimeout, ms, reject, message)
79      if (typeof timer === 'object') timer.unref?.()
80    })
81    return Promise.race([promise, timeoutPromise]).finally(() => {
82      if (timer !== undefined) clearTimeout(timer)
83    })
84  }