@@ -52,7 +52,7 @@ const defaultConfig = {
5252 * @returns {Object } ConcurrencyQueue instance with request/response interceptors attached to Axios.
5353 * @throws {Error } If axios instance is not provided or configuration is invalid.
5454 */
55- export function ConcurrencyQueue ( { axios, config } ) {
55+ export function ConcurrencyQueue ( { axios, config, plugins = [ ] } ) {
5656 if ( ! axios ) {
5757 throw Error ( ERROR_MESSAGES . AXIOS_INSTANCE_MISSING )
5858 }
@@ -172,7 +172,7 @@ export function ConcurrencyQueue ({ axios, config }) {
172172 logFinalFailure ( errorInfo , this . config . maxNetworkRetries )
173173 // Final error message
174174 const finalError = new Error ( `Network request failed after ${ this . config . maxNetworkRetries } retries: ${ errorInfo . reason } ` )
175- finalError . code = error . code
175+ finalError . code = error && error . code
176176 finalError . originalError = error
177177 finalError . retryAttempts = attempt - 1
178178 return Promise . reject ( finalError )
@@ -181,6 +181,16 @@ export function ConcurrencyQueue ({ axios, config }) {
181181 const delay = calculateNetworkRetryDelay ( attempt )
182182 logRetryAttempt ( errorInfo , attempt , delay )
183183
184+ // Guard: retry failures (e.g. from nested retries) may not have config in some
185+ // environments. Reject with a catchable error instead of throwing TypeError.
186+ if ( ! error || ! error . config ) {
187+ const finalError = new Error ( `Network request failed after retries: ${ errorInfo . reason } ` )
188+ finalError . code = error && error . code
189+ finalError . originalError = error
190+ finalError . retryAttempts = attempt - 1
191+ return Promise . reject ( finalError )
192+ }
193+
184194 // Initialize retry count if not present
185195 if ( ! error . config . networkRetryCount ) {
186196 error . config . networkRetryCount = 0
@@ -200,9 +210,7 @@ export function ConcurrencyQueue ({ axios, config }) {
200210 safeAxiosRequest ( requestConfig )
201211 . then ( ( response ) => {
202212 // On successful retry, call the original onComplete to properly clean up
203- if ( error . config . onComplete ) {
204- error . config . onComplete ( )
205- }
213+ error ?. config ?. onComplete ?. ( )
206214 shift ( ) // Process next queued request
207215 resolve ( response )
208216 } )
@@ -214,17 +222,13 @@ export function ConcurrencyQueue ({ axios, config }) {
214222 . then ( resolve )
215223 . catch ( ( finalError ) => {
216224 // On final failure, clean up the running queue
217- if ( error . config . onComplete ) {
218- error . config . onComplete ( )
219- }
225+ error ?. config ?. onComplete ?. ( )
220226 shift ( ) // Process next queued request
221227 reject ( finalError )
222228 } )
223229 } else {
224230 // On non-retryable error, clean up the running queue
225- if ( error . config . onComplete ) {
226- error . config . onComplete ( )
227- }
231+ error ?. config ?. onComplete ?. ( )
228232 shift ( ) // Process next queued request
229233 reject ( retryError )
230234 }
@@ -429,50 +433,101 @@ export function ConcurrencyQueue ({ axios, config }) {
429433 }
430434 } )
431435 }
432- // Response interceptor used for
436+ // Response interceptor used for success and for error path (Promise.reject(responseHandler(err))).
437+ // When used with an error, err may lack config (e.g. plugin returns new error). Guard so we don't throw.
433438 const responseHandler = ( response ) => {
434- response . config . onComplete ( )
439+ if ( response ?. config ?. onComplete ) {
440+ response . config . onComplete ( )
441+ }
435442 shift ( )
436443 return response
437444 }
438445
446+ // Run plugin onResponse hooks for errors so plugins see every error (including
447+ // those that are rejected without going through the plugin response interceptor).
448+ const runPluginOnResponseForError = ( error ) => {
449+ if ( ! plugins || ! plugins . length ) return error
450+ let currentError = error
451+ for ( const plugin of plugins ) {
452+ try {
453+ if ( typeof plugin . onResponse === 'function' ) {
454+ const result = plugin . onResponse ( currentError )
455+ if ( result !== undefined ) currentError = result
456+ }
457+ } catch ( e ) {
458+ if ( this . config . logHandler ) {
459+ this . config . logHandler ( 'error' , {
460+ name : 'PluginError' ,
461+ message : `Error in plugin onResponse (error handler): ${ e . message } ` ,
462+ error : e
463+ } )
464+ }
465+ }
466+ }
467+ return currentError
468+ }
469+
439470 const responseErrorHandler = error => {
440- let networkError = error . config . retryCount
471+ // Guard: Axios errors normally have config; missing config can occur when a retry
472+ // fails in certain environments or when non-Axios errors propagate (e.g. timeouts).
473+ // Reject with a catchable error instead of throwing TypeError and crashing the process.
474+ if ( ! error || ! error . config ) {
475+ const fallbackError = new Error (
476+ error && typeof error . message === 'string'
477+ ? error . message
478+ : 'Network request failed: error object missing request config'
479+ )
480+ fallbackError . code = error ?. code
481+ fallbackError . originalError = error
482+ return Promise . reject ( runPluginOnResponseForError ( fallbackError ) )
483+ }
484+
485+ let networkError = error ?. config ?. retryCount ?? 0
441486 let retryErrorType = null
442487
443488 // First, check for transient network errors
444489 const networkErrorInfo = isTransientNetworkError ( error )
445490 if ( networkErrorInfo && this . config . retryOnNetworkFailure ) {
446- const networkRetryCount = error . config . networkRetryCount || 0
491+ const networkRetryCount = error ? .config ? .networkRetryCount || 0
447492 return retryNetworkError ( error , networkErrorInfo , networkRetryCount + 1 )
448493 }
449494
450495 // Original retry logic for non-network errors
451496 if ( ! this . config . retryOnError || networkError > this . config . retryLimit ) {
452- return Promise . reject ( responseHandler ( error ) )
497+ const err = runPluginOnResponseForError ( error )
498+ return Promise . reject ( responseHandler ( err ) )
453499 }
454500
455501 // Check rate limit remaining header before retrying
456502 const wait = this . config . retryDelay
457503 var response = error . response
458504 if ( ! response ) {
459505 if ( error . code === 'ECONNABORTED' ) {
460- const timeoutMs = error . config . timeout || this . config . timeout || 'unknown'
506+ const timeoutMs = error ? .config ? .timeout || this . config . timeout || 'unknown'
461507 error . response = {
462508 ...error . response ,
463509 status : 408 ,
464510 statusText : `timeout of ${ timeoutMs } ms exceeded`
465511 }
466512 response = error . response
467513 } else {
468- return Promise . reject ( responseHandler ( error ) )
514+ const err = runPluginOnResponseForError ( error )
515+ return Promise . reject ( responseHandler ( err ) )
469516 }
470517 } else if ( ( response . status === 401 && this . config . refreshToken ) ) {
518+ // If error_code is 294 (2FA required), don't retry/refresh - pass through the error as-is
519+ const apiErrorCode = response . data ?. error_code
520+ if ( apiErrorCode === 294 ) {
521+ const err = runPluginOnResponseForError ( error )
522+ return Promise . reject ( responseHandler ( err ) )
523+ }
524+
471525 retryErrorType = `Error with status: ${ response . status } `
472526 networkError ++
473527
474528 if ( networkError > this . config . retryLimit ) {
475- return Promise . reject ( responseHandler ( error ) )
529+ const err = runPluginOnResponseForError ( error )
530+ return Promise . reject ( responseHandler ( err ) )
476531 }
477532 this . running . shift ( )
478533 // Cool down the running requests
@@ -486,19 +541,22 @@ export function ConcurrencyQueue ({ axios, config }) {
486541 networkError ++
487542 return this . retry ( error , retryErrorType , networkError , wait )
488543 }
489- return Promise . reject ( responseHandler ( error ) )
544+ const err = runPluginOnResponseForError ( error )
545+ return Promise . reject ( responseHandler ( err ) )
490546 }
491547
492548 this . retry = ( error , retryErrorType , retryCount , waittime ) => {
493549 let delaytime = waittime
494550 if ( retryCount > this . config . retryLimit ) {
495- return Promise . reject ( responseHandler ( error ) )
551+ const err = runPluginOnResponseForError ( error )
552+ return Promise . reject ( responseHandler ( err ) )
496553 }
497554 if ( this . config . retryDelayOptions ) {
498555 if ( this . config . retryDelayOptions . customBackoff ) {
499556 delaytime = this . config . retryDelayOptions . customBackoff ( retryCount , error )
500557 if ( delaytime && delaytime <= 0 ) {
501- return Promise . reject ( responseHandler ( error ) )
558+ const err = runPluginOnResponseForError ( error )
559+ return Promise . reject ( responseHandler ( err ) )
502560 }
503561 } else if ( this . config . retryDelayOptions . base ) {
504562 delaytime = this . config . retryDelayOptions . base * retryCount
0 commit comments