From bd3816bf9eaff677c3e278df9b03bcd302e9177b Mon Sep 17 00:00:00 2001 From: AKIBUZZAMAN AKIB Date: Sat, 25 Apr 2026 22:53:24 +0600 Subject: [PATCH] fix(dispatchRequest): attach response to AxiosError on JSON parse failure (#10724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dispatchRequest): attach response to AxiosError on JSON parse failure When JSON.parse fails in strict mode (responseType: 'json' or silentJSONParsing: false), the thrown AxiosError was missing both error.response and error.status because transformResponse has no direct access to the response object — only the config is available as `this` inside transformData. Root cause: transformData is called as transformData.call(config, ...) and AxiosError.from uses `this.response` to populate the error's response field, but config.response was never set. Fix: in dispatchRequest, temporarily assign the response object to config.response before invoking transformData, then clean it up in a finally block to avoid permanently polluting the config object. This ensures error.response and error.status are available when a request with responseType='json' receives a malformed JSON body, making the error consistent with all other AxiosError instances. Fixes #7224 * chore: added additional testing and removed the misleading test --------- Co-authored-by: AKIBUZZAMAN AKIB Co-authored-by: Jason Saayman --- lib/core/dispatchRequest.js | 26 +++-- tests/unit/core/dispatchRequest.test.js | 146 ++++++++++++++++++++++++ tests/unit/transformResponse.test.js | 41 +++++++ 3 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 tests/unit/core/dispatchRequest.test.js diff --git a/lib/core/dispatchRequest.js b/lib/core/dispatchRequest.js index 2015b54..59662d4 100644 --- a/lib/core/dispatchRequest.js +++ b/lib/core/dispatchRequest.js @@ -49,8 +49,15 @@ export default function dispatchRequest(config) { function onAdapterResolution(response) { throwIfCancellationRequested(config); - // Transform response data - response.data = transformData.call(config, config.transformResponse, response); + // Expose the current response on config so that transformResponse can + // attach it to any AxiosError it throws (e.g. on JSON parse failure). + // We clean it up afterwards to avoid polluting the config object. + config.response = response; + try { + response.data = transformData.call(config, config.transformResponse, response); + } finally { + delete config.response; + } response.headers = AxiosHeaders.from(response.headers); @@ -62,11 +69,16 @@ export default function dispatchRequest(config) { // Transform response data if (reason && reason.response) { - reason.response.data = transformData.call( - config, - config.transformResponse, - reason.response - ); + config.response = reason.response; + try { + reason.response.data = transformData.call( + config, + config.transformResponse, + reason.response + ); + } finally { + delete config.response; + } reason.response.headers = AxiosHeaders.from(reason.response.headers); } } diff --git a/tests/unit/core/dispatchRequest.test.js b/tests/unit/core/dispatchRequest.test.js new file mode 100644 index 0000000..b2416a3 --- /dev/null +++ b/tests/unit/core/dispatchRequest.test.js @@ -0,0 +1,146 @@ +import { describe, it } from 'vitest'; +import assert from 'assert'; +import dispatchRequest from '../../../lib/core/dispatchRequest.js'; +import AxiosError from '../../../lib/core/AxiosError.js'; +import defaults from '../../../lib/defaults/index.js'; + +function baseConfig(overrides = {}) { + return { + method: 'get', + url: '/test', + headers: {}, + transformRequest: defaults.transformRequest, + transformResponse: defaults.transformResponse, + transitional: { silentJSONParsing: false, forcedJSONParsing: true }, + responseType: 'json', + ...overrides, + }; +} + +describe('core::dispatchRequest', () => { + describe('JSON parse failure on adapter resolution', () => { + it('rejects with AxiosError carrying response and status', async () => { + const response = { + data: '{bad json', + status: 418, + statusText: "I'm a teapot", + headers: {}, + config: null, + request: {}, + }; + const config = baseConfig({ adapter: () => Promise.resolve(response) }); + + let thrown; + try { + await dispatchRequest(config); + } catch (e) { + thrown = e; + } + + assert.ok(thrown instanceof AxiosError, 'must be AxiosError'); + assert.strictEqual(thrown.code, AxiosError.ERR_BAD_RESPONSE); + assert.strictEqual(thrown.response, response, 'error.response must be the original response'); + assert.strictEqual(thrown.status, 418, 'error.status must equal response status'); + }); + + it('cleans up config.response after the transform throws', async () => { + const response = { + data: '{bad json', + status: 200, + statusText: 'OK', + headers: {}, + config: null, + request: {}, + }; + const config = baseConfig({ adapter: () => Promise.resolve(response) }); + + try { + await dispatchRequest(config); + } catch (_) { + // expected + } + + assert.strictEqual( + Object.prototype.hasOwnProperty.call(config, 'response'), + false, + 'config.response must be deleted in finally' + ); + }); + }); + + describe('JSON parse failure on adapter rejection', () => { + it('rejects with AxiosError carrying response and status (rejection path)', async () => { + const response = { + data: '{bad json', + status: 500, + statusText: 'Internal Server Error', + headers: {}, + config: null, + request: {}, + }; + const reason = new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE); + reason.response = response; + const config = baseConfig({ adapter: () => Promise.reject(reason) }); + + let thrown; + try { + await dispatchRequest(config); + } catch (e) { + thrown = e; + } + + assert.ok(thrown instanceof AxiosError, 'must be AxiosError'); + assert.strictEqual(thrown.response, response, 'error.response must be the original response'); + assert.strictEqual(thrown.status, 500, 'error.status must equal response status'); + }); + + it('cleans up config.response after the rejection-path transform', async () => { + const response = { + data: '{bad json', + status: 500, + statusText: 'Internal Server Error', + headers: {}, + config: null, + request: {}, + }; + const reason = new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE); + reason.response = response; + const config = baseConfig({ adapter: () => Promise.reject(reason) }); + + try { + await dispatchRequest(config); + } catch (_) { + // expected + } + + assert.strictEqual( + Object.prototype.hasOwnProperty.call(config, 'response'), + false, + 'config.response must be deleted in finally on the rejection path' + ); + }); + }); + + describe('happy path', () => { + it('cleans up config.response after a successful resolution', async () => { + const response = { + data: '{"ok":true}', + status: 200, + statusText: 'OK', + headers: {}, + config: null, + request: {}, + }; + const config = baseConfig({ adapter: () => Promise.resolve(response) }); + + const result = await dispatchRequest(config); + + assert.deepStrictEqual(result.data, { ok: true }); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(config, 'response'), + false, + 'config.response must not be left set after a successful request' + ); + }); + }); +}); diff --git a/tests/unit/transformResponse.test.js b/tests/unit/transformResponse.test.js index 2892e77..0b88c88 100644 --- a/tests/unit/transformResponse.test.js +++ b/tests/unit/transformResponse.test.js @@ -1,6 +1,7 @@ import { describe, it } from 'vitest'; import defaults from '../../lib/defaults/index.js'; import transformData from '../../lib/core/transformData.js'; +import AxiosError from '../../lib/core/AxiosError.js'; import assert from 'assert'; describe('transformResponse', () => { @@ -36,6 +37,46 @@ describe('transformResponse', () => { }); }); + describe('malformed JSON with responseType: json', () => { + it('throws AxiosError with ERR_BAD_RESPONSE code', () => { + const response = { status: 200, headers: {}, data: '{bad json' }; + const config = { + responseType: 'json', + transitional: { silentJSONParsing: false, forcedJSONParsing: true }, + response, + }; + + assert.throws( + () => transformData.call(config, defaults.transformResponse, response), + (e) => e instanceof AxiosError && e.code === AxiosError.ERR_BAD_RESPONSE + ); + }); + + it('attaches response to AxiosError so error.status and error.response are available', () => { + // Regression test for https://github.com/axios/axios/issues/7224 + // When JSON.parse fails in strict mode, the thrown AxiosError must carry + // the original response so callers can inspect error.status and + // error.response without having to re-examine the raw response. + const response = { status: 200, headers: {}, data: '{bad json' }; + const config = { + responseType: 'json', + transitional: { silentJSONParsing: false, forcedJSONParsing: true }, + response, + }; + + let thrown; + try { + transformData.call(config, defaults.transformResponse, response); + } catch (e) { + thrown = e; + } + + assert.ok(thrown instanceof AxiosError, 'must be AxiosError'); + assert.strictEqual(thrown.status, 200, 'error.status must equal response status'); + assert.strictEqual(thrown.response, response, 'error.response must be the original response'); + }); + }); + describe('204 request', () => { it('does not parse the empty string', () => { const data = '';