11import type { FFetchOptions , FFetch , FFetchRequestInit } from './types.js'
22import { retry , defaultDelay } from './retry.js'
3- // ...existing code...
43import { shouldRetry as defaultShouldRetry } from './should-retry.js'
54import { CircuitBreaker } from './circuit.js'
65import {
@@ -32,48 +31,51 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
3231 input : RequestInfo | URL ,
3332 init : FFetchRequestInit = { }
3433 ) => {
34+ // Check for AbortSignal.timeout before any async logic
35+ if (
36+ typeof AbortSignal === 'undefined' ||
37+ typeof AbortSignal . timeout !== 'function'
38+ ) {
39+ throw new Error (
40+ 'AbortSignal.timeout is required. Please use a polyfill for older environments.'
41+ )
42+ }
43+
3544 let request = new Request ( input , init )
36- // ...existing code...
45+
3746 // Merge hooks: per-request hooks override client hooks, but fallback to client hooks
3847 const effectiveHooks = { ...clientDefaultHooks , ...( init . hooks || { } ) }
3948 if ( effectiveHooks . transformRequest ) {
4049 request = await effectiveHooks . transformRequest ( request )
4150 }
4251 await effectiveHooks . before ?.( request )
43- // Combine two signals so abort from either source will abort the request
44- // ...existing code...
52+
53+ // AbortSignal.timeout/any logic ---
54+ const effectiveTimeout = init . timeout ?? clientDefaultTimeout
55+ const userSignal = init . signal
56+ let timeoutSignal : AbortSignal | undefined = undefined
57+ let combinedSignal : AbortSignal | undefined = undefined
58+ timeoutSignal = AbortSignal . timeout ( effectiveTimeout )
59+ if ( userSignal ) {
60+ if ( typeof AbortSignal . any === 'function' ) {
61+ combinedSignal = AbortSignal . any ( [ userSignal , timeoutSignal ] )
62+ } else {
63+ // Fallback: use userSignal if already aborted, else timeoutSignal
64+ combinedSignal = userSignal . aborted ? userSignal : timeoutSignal
65+ }
66+ } else {
67+ combinedSignal = timeoutSignal
68+ }
4569
4670 // Restore hook-wrapped retry, enforce global timeout externally
4771 const retryWithHooks = async ( ) => {
48- const effectiveTimeout = init . timeout ?? clientDefaultTimeout
4972 const effectiveRetries = init . retries ?? clientDefaultRetries
5073 const effectiveRetryDelay =
5174 typeof init . retryDelay !== 'undefined'
5275 ? init . retryDelay
5376 : clientDefaultRetryDelay
5477 const effectiveShouldRetry = init . shouldRetry ?? clientDefaultShouldRetry
5578
56- // Global timeout controller and elapsed time tracking
57- const timeoutCtrl = new AbortController ( )
58- const startTime = Date . now ( )
59- let didTimeout = false
60- const timeoutTimer = setTimeout ( ( ) => {
61- didTimeout = true
62- timeoutCtrl . abort ( )
63- } , effectiveTimeout )
64-
65- // Compose user and timeout signals
66- const userSignal = init . signal || undefined
67- function combinedSignal ( ) {
68- if ( ! userSignal ) return timeoutCtrl . signal
69- if ( userSignal . aborted ) {
70- timeoutCtrl . abort ( )
71- return timeoutCtrl . signal
72- }
73- userSignal . addEventListener ( 'abort' , ( ) => timeoutCtrl . abort ( ) )
74- return timeoutCtrl . signal
75- }
76-
7779 // Wrap shouldRetry to call onRetry hook
7880 let attempt = 0
7981 const shouldRetryWithHook = ( ctx : import ( './types' ) . RetryContext ) => {
@@ -90,91 +92,105 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
9092 return retrying
9193 }
9294
95+ function mapToCustomError ( err : unknown ) : unknown {
96+ if ( err instanceof DOMException && err . name === 'AbortError' ) {
97+ if ( timeoutSignal ?. aborted && ( ! userSignal || ! userSignal . aborted ) ) {
98+ return new TimeoutError ( 'signal timed out' , err )
99+ } else {
100+ return new AbortError ( 'Request was aborted' , err )
101+ }
102+ } else if (
103+ err instanceof TypeError &&
104+ / N e t w o r k E r r o r | n e t w o r k e r r o r | f a i l e d t o f e t c h | l o s t c o n n e c t i o n | N e t w o r k E r r o r w h e n a t t e m p t i n g t o f e t c h r e s o u r c e / i. test (
105+ err . message
106+ )
107+ ) {
108+ return new NetworkError ( err . message , err )
109+ }
110+ return err
111+ }
112+
113+ async function handleError ( err : unknown ) : Promise < never > {
114+ err = mapToCustomError ( err )
115+ // If user aborted, always throw AbortError, not RetryLimitError
116+ if ( userSignal ?. aborted ) {
117+ const abortErr = new AbortError ( 'Request was aborted by user' )
118+ await effectiveHooks . onAbort ?.( request )
119+ await effectiveHooks . onError ?.( request , abortErr )
120+ await effectiveHooks . onComplete ?.( request , undefined , abortErr )
121+ throw abortErr
122+ }
123+ // TimeoutError: call onTimeout hook and re-throw
124+ if ( err instanceof TimeoutError ) {
125+ await effectiveHooks . onTimeout ?.( request )
126+ await effectiveHooks . onError ?.( request , err )
127+ await effectiveHooks . onComplete ?.( request , undefined , err )
128+ throw err
129+ }
130+ // NetworkError: re-throw
131+ if ( err instanceof NetworkError ) {
132+ await effectiveHooks . onError ?.( request , err )
133+ await effectiveHooks . onComplete ?.( request , undefined , err )
134+ throw err
135+ }
136+ // AbortError: call onAbort hook and re-throw
137+ if ( err instanceof AbortError ) {
138+ await effectiveHooks . onAbort ?.( request )
139+ await effectiveHooks . onError ?.( request , err )
140+ await effectiveHooks . onComplete ?.( request , undefined , err )
141+ throw err
142+ }
143+ // Otherwise, throw RetryLimitError after all retries are exhausted
144+ const retryErr = new RetryLimitError (
145+ typeof err === 'object' &&
146+ err &&
147+ 'message' in err &&
148+ typeof ( err as { message ?: unknown } ) . message === 'string'
149+ ? ( err as { message : string } ) . message
150+ : 'Retry limit reached'
151+ )
152+ await effectiveHooks . onError ?.( request , retryErr )
153+ await effectiveHooks . onComplete ?.( request , undefined , retryErr )
154+ throw retryErr
155+ }
156+
93157 try {
94158 let res = await retry (
95159 async ( ) => {
96- // Check elapsed time before each attempt
97- const elapsed = Date . now ( ) - startTime
98- if ( elapsed >= effectiveTimeout ) {
99- didTimeout = true
100- await effectiveHooks . onTimeout ?.( request )
101- throw new TimeoutError ( 'Request timed out' )
160+ // Use AbortSignal.throwIfAborted() before starting fetch
161+ if ( typeof combinedSignal ?. throwIfAborted === 'function' ) {
162+ combinedSignal . throwIfAborted ( )
163+ } else if ( combinedSignal ?. aborted ) {
164+ throw new AbortError ( 'Request was aborted' )
102165 }
103166 const reqWithSignal = new Request ( request , {
104- signal : combinedSignal ( ) ,
167+ signal : combinedSignal ,
105168 } )
106169 try {
107170 const r = await fetch ( reqWithSignal )
108171 return r
109172 } catch ( err : unknown ) {
110- if ( err instanceof DOMException && err . name === 'AbortError' ) {
111- // Check elapsed time after abort
112- const elapsedAbort = Date . now ( ) - startTime
113- if ( userSignal ?. aborted ) {
114- await effectiveHooks . onAbort ?.( request )
115- throw new AbortError ( 'Request was aborted' )
116- } else if ( didTimeout || elapsedAbort >= effectiveTimeout ) {
117- await effectiveHooks . onTimeout ?.( request )
118- throw new TimeoutError ( 'Request timed out' )
119- } else {
120- throw new AbortError ( 'Request was aborted' )
121- }
122- } else if (
123- err instanceof Error &&
124- ( err . message . includes ( 'timeout' ) || err . name === 'TimeoutError' )
125- ) {
126- await effectiveHooks . onTimeout ?.( request )
127- throw new TimeoutError ( err . message )
128- } else if (
129- err instanceof TypeError &&
130- err . message &&
131- err . message . includes ( 'NetworkError' )
132- ) {
133- throw new NetworkError ( err . message )
134- }
135- throw err
173+ await handleError ( mapToCustomError ( err ) )
174+ throw new Error ( 'Unreachable: handleError should always throw' )
136175 }
137176 } ,
138177 effectiveRetries ,
139178 effectiveRetryDelay ,
140179 shouldRetryWithHook ,
141180 request
142181 )
143- clearTimeout ( timeoutTimer )
144182 if ( effectiveHooks . transformResponse ) {
145183 res = await effectiveHooks . transformResponse ( res , request )
146184 }
147185 await effectiveHooks . after ?.( request , res )
148186 await effectiveHooks . onComplete ?.( request , res , undefined )
149187 return res
150188 } catch ( err : unknown ) {
151- clearTimeout ( timeoutTimer )
152- // If the error is a known custom error, re-throw it directly
153- if (
154- err instanceof TimeoutError ||
155- err instanceof AbortError ||
156- err instanceof NetworkError
157- ) {
158- await effectiveHooks . onError ?.( request , err )
159- await effectiveHooks . onComplete ?.( request , undefined , err )
160- throw err
161- }
162- // Otherwise, throw RetryLimitError after all retries are exhausted
163- const retryErr = new RetryLimitError (
164- typeof err === 'object' &&
165- err &&
166- 'message' in err &&
167- typeof ( err as { message ?: unknown } ) . message === 'string'
168- ? ( err as { message : string } ) . message
169- : 'Retry limit reached'
170- )
171- await effectiveHooks . onError ?.( request , retryErr )
172- await effectiveHooks . onComplete ?.( request , undefined , retryErr )
173- throw retryErr
189+ await handleError ( mapToCustomError ( err ) )
190+ throw new Error ( 'Unreachable: handleError should always throw' )
174191 }
175192 }
176193
177- // ...replaced above...
178194 if ( breaker ) {
179195 try {
180196 return await breaker . invoke ( retryWithHooks )
0 commit comments