From 78b290c57c978ed2ab420b90d97350231c9e5d74 Mon Sep 17 00:00:00 2001 From: khani Date: Wed, 30 Jul 2025 11:27:27 +0330 Subject: [PATCH] =?UTF-8?q?feat(adapter):=20surface=20low=E2=80=91level=20?= =?UTF-8?q?network=20error=20details;=20attach=20original=20error=20via=20?= =?UTF-8?q?cause=20(#6982)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(adapter): surface low‑level network error details; attach original error via `cause` Node http adapter: - Promote low-level `err.code` to `AxiosError.code`, prefixing message (e.g. `ECONNREFUSED – …`) - Keep original error on standard `Error.cause` XHR adapter: - Preserve browser `ProgressEvent` on `error.event` - Use event message when available Tests: - Add Node ESM tests under `test/unit/adapters` to assert `code` and `cause` behavior Types: - Ensure `AxiosError.cause?: unknown` and `event?: ProgressEvent` are present * fix(adapter): use fs instead of fs/promises for sync file read in tests to fix GitHub Actions --- index.d.cts | 7 +- index.d.ts | 3 +- lib/adapters/xhr.js | 19 +++--- lib/core/AxiosError.js | 13 +++- test/unit/adapters/error-details.spec.js | 84 ++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 test/unit/adapters/error-details.spec.js diff --git a/index.d.cts b/index.d.cts index e0674b8..787f738 100644 --- a/index.d.cts +++ b/index.d.cts @@ -16,6 +16,8 @@ type ContentType = axios.AxiosHeaderValue | 'text/html' | 'text/plain' | 'multip type CommonResponseHeadersList = 'Server' | 'Content-Type' | 'Content-Length' | 'Cache-Control'| 'Content-Encoding'; +type BrowserProgressEvent = any; + declare class AxiosHeaders { constructor( headers?: RawAxiosHeaders | AxiosHeaders | string @@ -98,7 +100,8 @@ declare class AxiosError extends Error { isAxiosError: boolean; status?: number; toJSON: () => object; - cause?: Error; + cause?: unknown; + event?: BrowserProgressEvent; static from( error: Error | unknown, code?: string, @@ -352,8 +355,6 @@ declare namespace axios { type MaxDownloadRate = number; - type BrowserProgressEvent = any; - interface AxiosProgressEvent { loaded: number; total?: number; diff --git a/index.d.ts b/index.d.ts index d4d661f..c2ed195 100644 --- a/index.d.ts +++ b/index.d.ts @@ -418,7 +418,8 @@ export class AxiosError extends Error { isAxiosError: boolean; status?: number; toJSON: () => object; - cause?: Error; + cause?: unknown; + event?: BrowserProgressEvent; static from( error: Error | unknown, code?: string, diff --git a/lib/adapters/xhr.js b/lib/adapters/xhr.js index a7ee548..0223618 100644 --- a/lib/adapters/xhr.js +++ b/lib/adapters/xhr.js @@ -104,15 +104,18 @@ export default isXHRAdapterSupported && function (config) { }; // Handle low level network errors - request.onerror = function handleError() { - // Real errors are hidden from us by the browser - // onerror should only fire if it's a network error - reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request)); - - // Clean up request - request = null; + request.onerror = function handleError(event) { + // Browsers deliver a ProgressEvent in XHR onerror + // (message may be empty; when present, surface it) + // See https://developer.mozilla.org/docs/Web/API/XMLHttpRequest/error_event + const msg = event && event.message ? event.message : 'Network Error'; + const err = new AxiosError(msg, AxiosError.ERR_NETWORK, config, request); + // attach the underlying event for consumers who want details + err.event = event || null; + reject(err); + request = null; }; - + // Handle timeout request.ontimeout = function handleTimeout() { let timeoutErrorMessage = _config.timeout ? 'timeout of ' + _config.timeout + 'ms exceeded' : 'timeout exceeded'; diff --git a/lib/core/AxiosError.js b/lib/core/AxiosError.js index 73da248..3d118cb 100644 --- a/lib/core/AxiosError.js +++ b/lib/core/AxiosError.js @@ -89,11 +89,18 @@ AxiosError.from = (error, code, config, request, response, customProps) => { return prop !== 'isAxiosError'; }); - AxiosError.call(axiosError, error.message, code, config, request, response); + const msg = error && error.message ? error.message : 'Error'; - axiosError.cause = error; + // Prefer explicit code; otherwise copy the low-level error's code (e.g. ECONNREFUSED) + const errCode = code == null && error ? error.code : code; + AxiosError.call(axiosError, msg, errCode, config, request, response); - axiosError.name = error.name; + // Chain the original error on the standard field; non-enumerable to avoid JSON noise + if (error && axiosError.cause == null) { + Object.defineProperty(axiosError, 'cause', { value: error, configurable: true }); + } + + axiosError.name = (error && error.name) || 'Error'; customProps && Object.assign(axiosError, customProps); diff --git a/test/unit/adapters/error-details.spec.js b/test/unit/adapters/error-details.spec.js new file mode 100644 index 0000000..909c677 --- /dev/null +++ b/test/unit/adapters/error-details.spec.js @@ -0,0 +1,84 @@ +/* eslint-env mocha */ +import assert from 'assert'; +import https from 'https'; +import net from 'net'; +import fs from 'fs'; +import path from 'path'; +import {fileURLToPath} from 'url'; +import axios from '../../../index.js'; + +/** __dirname replacement for ESM */ +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Get a port that will refuse connections: bind to a random port and close it. */ +async function getClosedPort() { + return await new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(0, '127.0.0.1', () => { + const {port} = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} + +describe('adapters – network-error details', function () { + this.timeout(5000); + + it('should expose ECONNREFUSED and set error.cause on connection refusal', async function () { + const port = await getClosedPort(); + + try { + await axios.get(`http://127.0.0.1:${port}`, { timeout: 500 }); + assert.fail('request unexpectedly succeeded'); + } catch (err) { + assert.ok(err instanceof Error, 'should be an Error'); + assert.strictEqual(err.isAxiosError, true, 'isAxiosError should be true'); + + // New behavior: Node error code is surfaced and original error is linked via cause + assert.strictEqual(err.code, 'ECONNREFUSED'); + assert.ok('cause' in err, 'error.cause should exist'); + assert.ok(err.cause instanceof Error, 'cause should be an Error'); + assert.strictEqual(err.cause && err.cause.code, 'ECONNREFUSED'); + + // Message remains a string (content may include the code prefix) + assert.strictEqual(typeof err.message, 'string'); + } + }); + + it('should expose self-signed TLS error and set error.cause', async function () { + // Use the same certs already present for adapter tests in this folder + const keyPath = path.join(__dirname, 'key.pem'); + const certPath = path.join(__dirname, 'cert.pem'); + + const key = fs.readFileSync(keyPath); + const cert = fs.readFileSync(certPath); + + const httpsServer = https.createServer({ key, cert }, (req, res) => res.end('ok')); + + await new Promise((resolve) => httpsServer.listen(0, '127.0.0.1', resolve)); + const {port} = httpsServer.address(); + + try { + await axios.get(`https://127.0.0.1:${port}`, { + timeout: 500, + httpsAgent: new https.Agent({ rejectUnauthorized: true }) // Explicit: reject self-signed + }); + assert.fail('request unexpectedly succeeded'); + } catch (err) { + const codeStr = String(err.code); + // OpenSSL/Node variants: SELF_SIGNED_CERT_IN_CHAIN, DEPTH_ZERO_SELF_SIGNED_CERT, UNABLE_TO_VERIFY_LEAF_SIGNATURE + assert.ok(/SELF_SIGNED|UNABLE_TO_VERIFY_LEAF_SIGNATURE|DEPTH_ZERO/.test(codeStr), 'unexpected TLS code: ' + codeStr); + + assert.ok('cause' in err, 'error.cause should exist'); + assert.ok(err.cause instanceof Error, 'cause should be an Error'); + + const causeCode = String(err.cause && err.cause.code); + assert.ok(/SELF_SIGNED|UNABLE_TO_VERIFY_LEAF_SIGNATURE|DEPTH_ZERO/.test(causeCode), 'unexpected cause code: ' + causeCode); + + assert.strictEqual(typeof err.message, 'string'); + } finally { + await new Promise((resolve) => httpsServer.close(resolve)); + } + }); +});