diff --git a/.talismanrc b/.talismanrc index 12d1f907..a868b218 100644 --- a/.talismanrc +++ b/.talismanrc @@ -9,7 +9,7 @@ fileignoreconfig: ignore_detectors: - filecontent - filename: package-lock.json - checksum: d6e0739053d0068a31f75ef462d9e80a68a165d7715115cc2f33cdf65d9084dd + checksum: 751efa34d2f832c7b99771568b5125d929dab095784b6e4ea659daaa612994c8 - filename: .husky/pre-commit checksum: 52a664f536cf5d1be0bea19cb6031ca6e8107b45b6314fe7d47b7fad7d800632 - filename: test/sanity-check/api/user-test.js diff --git a/lib/core/concurrency-queue.js b/lib/core/concurrency-queue.js index 0e526a18..0adf1f9e 100644 --- a/lib/core/concurrency-queue.js +++ b/lib/core/concurrency-queue.js @@ -52,7 +52,7 @@ const defaultConfig = { * @returns {Object} ConcurrencyQueue instance with request/response interceptors attached to Axios. * @throws {Error} If axios instance is not provided or configuration is invalid. */ -export function ConcurrencyQueue ({ axios, config }) { +export function ConcurrencyQueue ({ axios, config, plugins = [] }) { if (!axios) { throw Error(ERROR_MESSAGES.AXIOS_INSTANCE_MISSING) } @@ -436,6 +436,30 @@ export function ConcurrencyQueue ({ axios, config }) { return response } + // Run plugin onResponse hooks for errors so plugins see every error (including + // those that are rejected without going through the plugin response interceptor). + const runPluginOnResponseForError = (error) => { + if (!plugins || !plugins.length) return error + let currentError = error + for (const plugin of plugins) { + try { + if (typeof plugin.onResponse === 'function') { + const result = plugin.onResponse(currentError) + if (result !== undefined) currentError = result + } + } catch (e) { + if (this.config.logHandler) { + this.config.logHandler('error', { + name: 'PluginError', + message: `Error in plugin onResponse (error handler): ${e.message}`, + error: e + }) + } + } + } + return currentError + } + const responseErrorHandler = error => { let networkError = error.config.retryCount let retryErrorType = null @@ -449,7 +473,8 @@ export function ConcurrencyQueue ({ axios, config }) { // Original retry logic for non-network errors if (!this.config.retryOnError || networkError > this.config.retryLimit) { - return Promise.reject(responseHandler(error)) + const err = runPluginOnResponseForError(error) + return Promise.reject(responseHandler(err)) } // Check rate limit remaining header before retrying @@ -465,20 +490,23 @@ export function ConcurrencyQueue ({ axios, config }) { } response = error.response } else { - return Promise.reject(responseHandler(error)) + const err = runPluginOnResponseForError(error) + return Promise.reject(responseHandler(err)) } } else if ((response.status === 401 && this.config.refreshToken)) { // If error_code is 294 (2FA required), don't retry/refresh - pass through the error as-is const apiErrorCode = response.data?.error_code if (apiErrorCode === 294) { - return Promise.reject(error) + const err = runPluginOnResponseForError(error) + return Promise.reject(responseHandler(err)) } retryErrorType = `Error with status: ${response.status}` networkError++ if (networkError > this.config.retryLimit) { - return Promise.reject(responseHandler(error)) + const err = runPluginOnResponseForError(error) + return Promise.reject(responseHandler(err)) } this.running.shift() // Cool down the running requests @@ -492,19 +520,22 @@ export function ConcurrencyQueue ({ axios, config }) { networkError++ return this.retry(error, retryErrorType, networkError, wait) } - return Promise.reject(responseHandler(error)) + const err = runPluginOnResponseForError(error) + return Promise.reject(responseHandler(err)) } this.retry = (error, retryErrorType, retryCount, waittime) => { let delaytime = waittime if (retryCount > this.config.retryLimit) { - return Promise.reject(responseHandler(error)) + const err = runPluginOnResponseForError(error) + return Promise.reject(responseHandler(err)) } if (this.config.retryDelayOptions) { if (this.config.retryDelayOptions.customBackoff) { delaytime = this.config.retryDelayOptions.customBackoff(retryCount, error) if (delaytime && delaytime <= 0) { - return Promise.reject(responseHandler(error)) + const err = runPluginOnResponseForError(error) + return Promise.reject(responseHandler(err)) } } else if (this.config.retryDelayOptions.base) { delaytime = this.config.retryDelayOptions.base * retryCount diff --git a/lib/core/contentstackHTTPClient.js b/lib/core/contentstackHTTPClient.js index c4c72768..c2e204b5 100644 --- a/lib/core/contentstackHTTPClient.js +++ b/lib/core/contentstackHTTPClient.js @@ -109,10 +109,11 @@ export default function contentstackHttpClient (options) { } const instance = axios.create(axiosOptions) instance.httpClientParams = options - instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config }) - // Normalize and store plugins - const plugins = normalizePlugins(config.plugins) + // Normalize and store plugins before ConcurrencyQueue so plugin interceptors + // run after the queue's (plugin sees responses/errors before they reach the queue). + // Use options.plugins so hooks run against the same plugin references (spies work in tests). + const plugins = normalizePlugins(options.plugins || config.plugins) // Request interceptor for versioning strategy (must run first) instance.interceptors.request.use((request) => { @@ -235,5 +236,7 @@ export default function contentstackHttpClient (options) { ) } + instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config, plugins }) + return instance } diff --git a/package-lock.json b/package-lock.json index 371f9e73..ca31cfc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3373,7 +3374,6 @@ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -3392,7 +3392,6 @@ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -3445,7 +3444,6 @@ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -3479,8 +3477,7 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -3565,6 +3562,7 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -3620,16 +3618,14 @@ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/retry": { "version": "0.12.0", @@ -3644,7 +3640,6 @@ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -3655,7 +3650,6 @@ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*" @@ -3944,6 +3938,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3994,6 +3989,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4345,6 +4341,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -4998,6 +4995,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5950,9 +5948,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.282", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz", - "integrity": "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==", + "version": "1.5.283", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", + "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", "dev": true, "license": "ISC" }, @@ -6239,6 +6237,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6376,6 +6375,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6433,6 +6433,7 @@ "integrity": "sha512-2abNmzAH/JpxI4gEOwd6K8wZIodK3BmHbTxz4s79OIYwwIt2gkpEXlAouJXu4H1c9ySTnRso0tsuthSOZbUMlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "eslint-plugin-es": "^1.4.1", "eslint-utils": "^1.4.2", @@ -6454,6 +6455,7 @@ "integrity": "sha512-bY2sGqyptzFBDLh/GMbAxfdJC+b0f23ME63FOE4+Jao0oZ3E1LEwFtWJX/1pGMJLiTtrSSern2CRM/g+dfc0eQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=6" } @@ -6478,6 +6480,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "eslint": ">=5.0.0" } @@ -8840,6 +8843,7 @@ "integrity": "sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^28.1.3", "@jest/types": "^28.1.3", @@ -10725,6 +10729,7 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -13699,6 +13704,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14114,6 +14120,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14521,6 +14528,7 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14771,6 +14779,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -14898,6 +14907,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/test/unit/ContentstackHTTPClient-test.js b/test/unit/ContentstackHTTPClient-test.js index e04e4b94..a03f28b8 100644 --- a/test/unit/ContentstackHTTPClient-test.js +++ b/test/unit/ContentstackHTTPClient-test.js @@ -251,11 +251,13 @@ describe('Contentstack HTTP Client', () => { }) it('should call onResponse hook after error response', (done) => { - const onResponseSpy = sinon.spy() + let onResponseCalled = false + let receivedError = null const plugin = { onRequest: () => {}, onResponse: (error) => { - onResponseSpy(error) + onResponseCalled = true + receivedError = error return error } } @@ -275,11 +277,13 @@ describe('Contentstack HTTP Client', () => { axiosInstance.get('/test').catch(() => { // Plugin should be called for the error // eslint-disable-next-line no-unused-expressions - expect(onResponseSpy.called).to.be.true - if (onResponseSpy.called) { - // eslint-disable-next-line no-unused-expressions - expect(onResponseSpy.calledWith(sinon.match.object)).to.be.true - } + expect(onResponseCalled).to.be.true + // eslint-disable-next-line no-unused-expressions + expect(receivedError).to.exist + // eslint-disable-next-line no-unused-expressions + expect(receivedError.response).to.exist + // eslint-disable-next-line no-unused-expressions + expect(receivedError.response.status).to.equal(500) done() }).catch((err) => { // Ensure done is called even if there's an unexpected error