Skip to content

Commit 98a97b3

Browse files
Merge pull request #498 from contentstack/fix/run-plugin-onresponse-on-error
Enhance ConcurrencyQueue to support plugin error handling and update …
2 parents bd53c2e + 1f5479a commit 98a97b3

File tree

5 files changed

+81
-33
lines changed

5 files changed

+81
-33
lines changed

.talismanrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ fileignoreconfig:
99
ignore_detectors:
1010
- filecontent
1111
- filename: package-lock.json
12-
checksum: d6e0739053d0068a31f75ef462d9e80a68a165d7715115cc2f33cdf65d9084dd
12+
checksum: 751efa34d2f832c7b99771568b5125d929dab095784b6e4ea659daaa612994c8
1313
- filename: .husky/pre-commit
1414
checksum: 52a664f536cf5d1be0bea19cb6031ca6e8107b45b6314fe7d47b7fad7d800632
1515
- filename: test/sanity-check/api/user-test.js

lib/core/concurrency-queue.js

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}
@@ -436,6 +436,30 @@ export function ConcurrencyQueue ({ axios, config }) {
436436
return response
437437
}
438438

439+
// Run plugin onResponse hooks for errors so plugins see every error (including
440+
// those that are rejected without going through the plugin response interceptor).
441+
const runPluginOnResponseForError = (error) => {
442+
if (!plugins || !plugins.length) return error
443+
let currentError = error
444+
for (const plugin of plugins) {
445+
try {
446+
if (typeof plugin.onResponse === 'function') {
447+
const result = plugin.onResponse(currentError)
448+
if (result !== undefined) currentError = result
449+
}
450+
} catch (e) {
451+
if (this.config.logHandler) {
452+
this.config.logHandler('error', {
453+
name: 'PluginError',
454+
message: `Error in plugin onResponse (error handler): ${e.message}`,
455+
error: e
456+
})
457+
}
458+
}
459+
}
460+
return currentError
461+
}
462+
439463
const responseErrorHandler = error => {
440464
let networkError = error.config.retryCount
441465
let retryErrorType = null
@@ -449,7 +473,8 @@ export function ConcurrencyQueue ({ axios, config }) {
449473

450474
// Original retry logic for non-network errors
451475
if (!this.config.retryOnError || networkError > this.config.retryLimit) {
452-
return Promise.reject(responseHandler(error))
476+
const err = runPluginOnResponseForError(error)
477+
return Promise.reject(responseHandler(err))
453478
}
454479

455480
// Check rate limit remaining header before retrying
@@ -465,20 +490,23 @@ export function ConcurrencyQueue ({ axios, config }) {
465490
}
466491
response = error.response
467492
} else {
468-
return Promise.reject(responseHandler(error))
493+
const err = runPluginOnResponseForError(error)
494+
return Promise.reject(responseHandler(err))
469495
}
470496
} else if ((response.status === 401 && this.config.refreshToken)) {
471497
// If error_code is 294 (2FA required), don't retry/refresh - pass through the error as-is
472498
const apiErrorCode = response.data?.error_code
473499
if (apiErrorCode === 294) {
474-
return Promise.reject(error)
500+
const err = runPluginOnResponseForError(error)
501+
return Promise.reject(responseHandler(err))
475502
}
476503

477504
retryErrorType = `Error with status: ${response.status}`
478505
networkError++
479506

480507
if (networkError > this.config.retryLimit) {
481-
return Promise.reject(responseHandler(error))
508+
const err = runPluginOnResponseForError(error)
509+
return Promise.reject(responseHandler(err))
482510
}
483511
this.running.shift()
484512
// Cool down the running requests
@@ -492,19 +520,22 @@ export function ConcurrencyQueue ({ axios, config }) {
492520
networkError++
493521
return this.retry(error, retryErrorType, networkError, wait)
494522
}
495-
return Promise.reject(responseHandler(error))
523+
const err = runPluginOnResponseForError(error)
524+
return Promise.reject(responseHandler(err))
496525
}
497526

498527
this.retry = (error, retryErrorType, retryCount, waittime) => {
499528
let delaytime = waittime
500529
if (retryCount > this.config.retryLimit) {
501-
return Promise.reject(responseHandler(error))
530+
const err = runPluginOnResponseForError(error)
531+
return Promise.reject(responseHandler(err))
502532
}
503533
if (this.config.retryDelayOptions) {
504534
if (this.config.retryDelayOptions.customBackoff) {
505535
delaytime = this.config.retryDelayOptions.customBackoff(retryCount, error)
506536
if (delaytime && delaytime <= 0) {
507-
return Promise.reject(responseHandler(error))
537+
const err = runPluginOnResponseForError(error)
538+
return Promise.reject(responseHandler(err))
508539
}
509540
} else if (this.config.retryDelayOptions.base) {
510541
delaytime = this.config.retryDelayOptions.base * retryCount

lib/core/contentstackHTTPClient.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,11 @@ export default function contentstackHttpClient (options) {
109109
}
110110
const instance = axios.create(axiosOptions)
111111
instance.httpClientParams = options
112-
instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config })
113112

114-
// Normalize and store plugins
115-
const plugins = normalizePlugins(config.plugins)
113+
// Normalize and store plugins before ConcurrencyQueue so plugin interceptors
114+
// run after the queue's (plugin sees responses/errors before they reach the queue).
115+
// Use options.plugins so hooks run against the same plugin references (spies work in tests).
116+
const plugins = normalizePlugins(options.plugins || config.plugins)
116117

117118
// Request interceptor for versioning strategy (must run first)
118119
instance.interceptors.request.use((request) => {
@@ -235,5 +236,7 @@ export default function contentstackHttpClient (options) {
235236
)
236237
}
237238

239+
instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config, plugins })
240+
238241
return instance
239242
}

package-lock.json

Lines changed: 24 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/unit/ContentstackHTTPClient-test.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,13 @@ describe('Contentstack HTTP Client', () => {
251251
})
252252

253253
it('should call onResponse hook after error response', (done) => {
254-
const onResponseSpy = sinon.spy()
254+
let onResponseCalled = false
255+
let receivedError = null
255256
const plugin = {
256257
onRequest: () => {},
257258
onResponse: (error) => {
258-
onResponseSpy(error)
259+
onResponseCalled = true
260+
receivedError = error
259261
return error
260262
}
261263
}
@@ -275,11 +277,13 @@ describe('Contentstack HTTP Client', () => {
275277
axiosInstance.get('/test').catch(() => {
276278
// Plugin should be called for the error
277279
// eslint-disable-next-line no-unused-expressions
278-
expect(onResponseSpy.called).to.be.true
279-
if (onResponseSpy.called) {
280-
// eslint-disable-next-line no-unused-expressions
281-
expect(onResponseSpy.calledWith(sinon.match.object)).to.be.true
282-
}
280+
expect(onResponseCalled).to.be.true
281+
// eslint-disable-next-line no-unused-expressions
282+
expect(receivedError).to.exist
283+
// eslint-disable-next-line no-unused-expressions
284+
expect(receivedError.response).to.exist
285+
// eslint-disable-next-line no-unused-expressions
286+
expect(receivedError.response.status).to.equal(500)
283287
done()
284288
}).catch((err) => {
285289
// Ensure done is called even if there's an unexpected error

0 commit comments

Comments
 (0)