/ src / lib / fetch-timeout.ts
fetch-timeout.ts
 1  const MIN_TIMEOUT_MS = 1_000
 2  
 3  function createTimeoutError(timeoutMs: number): Error {
 4    const error = new Error(`Request timed out after ${timeoutMs}ms`)
 5    error.name = 'TimeoutError'
 6    return error
 7  }
 8  
 9  function abortWithReason(controller: AbortController, reason: unknown): void {
10    try {
11      controller.abort(reason)
12    } catch {
13      controller.abort()
14    }
15  }
16  
17  function combineAbortSignals(signals: AbortSignal[]): AbortSignal {
18    if (signals.length === 1) return signals[0]
19    if (typeof AbortSignal.any === 'function') return AbortSignal.any(signals)
20  
21    const controller = new AbortController()
22    const listeners = new Map<AbortSignal, () => void>()
23    const abortFrom = (signal: AbortSignal) => {
24      for (const [candidate, listener] of listeners.entries()) {
25        candidate.removeEventListener('abort', listener)
26      }
27      abortWithReason(controller, signal.reason)
28    }
29  
30    for (const signal of signals) {
31      if (signal.aborted) {
32        abortFrom(signal)
33        break
34      }
35      const listener = () => abortFrom(signal)
36      listeners.set(signal, listener)
37      signal.addEventListener('abort', listener, { once: true })
38    }
39  
40    return controller.signal
41  }
42  
43  export function isAbortError(err: unknown): boolean {
44    return Boolean(err) && typeof err === 'object' && (err as { name?: string }).name === 'AbortError'
45  }
46  
47  export function isTimeoutError(err: unknown): boolean {
48    return Boolean(err) && typeof err === 'object' && (err as { name?: string }).name === 'TimeoutError'
49  }
50  
51  export async function fetchWithTimeout(
52    input: RequestInfo | URL,
53    init: RequestInit = {},
54    timeoutMs: number,
55  ): Promise<Response> {
56    const boundedTimeout = Math.max(MIN_TIMEOUT_MS, Math.trunc(timeoutMs))
57    const timeoutController = new AbortController()
58    const timeoutError = createTimeoutError(boundedTimeout)
59    const signal = init.signal
60      ? combineAbortSignals([init.signal, timeoutController.signal])
61      : timeoutController.signal
62    const timer = setTimeout(() => abortWithReason(timeoutController, timeoutError), boundedTimeout)
63  
64    try {
65      return await fetch(input, { ...init, signal })
66    } catch (err) {
67      if (timeoutController.signal.aborted && isTimeoutError(timeoutController.signal.reason)) {
68        throw timeoutController.signal.reason
69      }
70      throw err
71    } finally {
72      clearTimeout(timer)
73    }
74  }