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 }