diff --git a/lib/adapters/fetch.js b/lib/adapters/fetch.js index bc79778d..df47fd71 100644 --- a/lib/adapters/fetch.js +++ b/lib/adapters/fetch.js @@ -409,6 +409,17 @@ const factory = (env) => { } catch (err) { unsubscribe && unsubscribe(); + // Safari can surface fetch aborts as a DOMException-like object whose + // branded getters throw. Prefer our composed signal reason before reading + // the caught error, preserving timeout vs cancellation semantics. + if (composedSignal && composedSignal.aborted && composedSignal.reason instanceof AxiosError) { + const canceledError = composedSignal.reason; + canceledError.config = config; + request && (canceledError.request = request); + err !== canceledError && (canceledError.cause = err); + throw canceledError; + } + if (err && err.name === 'TypeError' && /Load failed|fetch/i.test(err.message)) { throw Object.assign( new AxiosError( diff --git a/tests/smoke/bun/tests/timeout.smoke.test.ts b/tests/smoke/bun/tests/timeout.smoke.test.ts index c6d2c989..8405ee16 100644 --- a/tests/smoke/bun/tests/timeout.smoke.test.ts +++ b/tests/smoke/bun/tests/timeout.smoke.test.ts @@ -15,7 +15,7 @@ const createAbortedError = () => { }; describe('timeout', () => { - test('timeout: 50 with never-resolving fetch mock rejects with ECONNABORTED', async () => { + test('timeout: 50 with never-resolving fetch mock rejects with ETIMEDOUT', async () => { const fetch = (input: unknown, init?: RequestInit) => new Promise((_resolve, reject) => { const signal = init?.signal || (input instanceof Request ? input.signal : undefined); @@ -45,6 +45,6 @@ describe('timeout', () => { .catch((e: any) => e); expect(axios.isAxiosError(err)).toBe(true); - expect(err.code).toBe('ECONNABORTED'); + expect(err.code).toBe('ETIMEDOUT'); }); }); diff --git a/tests/unit/adapters/fetch.test.js b/tests/unit/adapters/fetch.test.js index e8e25fa7..a9e51cad 100644 --- a/tests/unit/adapters/fetch.test.js +++ b/tests/unit/adapters/fetch.test.js @@ -26,6 +26,29 @@ const fetchAxios = axios.create({ adapter: 'fetch', }); +const getFetchSignal = (input, init) => (init && init.signal) || (input && input.signal); + +const createBrokenDOMExceptionLikeError = () => + Object.defineProperties( + {}, + { + name: { + get() { + throw new TypeError( + 'The DOMException.name getter can only be used on instances of DOMException' + ); + }, + }, + message: { + get() { + throw new TypeError( + 'The DOMException.message getter can only be used on instances of DOMException' + ); + }, + }, + } + ); + describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => { it('should sanitize request headers containing CRLF characters', async () => { const server = await startHTTPServer( @@ -470,7 +493,7 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => await setTimeoutAsync(1000); res.end('OK'); }, - { port: SERVER_PORT } + { port: 0 } ); try { @@ -492,6 +515,100 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => } }); + describe('fetch adapter - timeout normalization', () => { + it('should reject with an AxiosError(ETIMEDOUT) on timeout', async () => { + const server = await startHTTPServer( + async (req, res) => { + await setTimeoutAsync(1000); + res.end('OK'); + }, + { port: 0 } + ); + + try { + await assert.rejects( + () => + fetchAxios(`http://localhost:${server.address().port}/`, { + timeout: 200, + }), + (err) => { + assert.strictEqual(err.name, 'AxiosError'); + assert.strictEqual(err.code, 'ETIMEDOUT'); + assert.match(err.message, /timeout of 200ms exceeded/); + return true; + } + ); + } finally { + await stopHTTPServer(server); + } + }); + + it('should not classify a user-initiated abort as a timeout', async () => { + const safariFetch = (url, init) => { + const signal = getFetchSignal(url, init); + + return new Promise((_resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(createBrokenDOMExceptionLikeError()); + }; + + if (signal.aborted) return onAbort(); + signal.addEventListener('abort', onAbort); + }); + }; + + const controller = new AbortController(); + + const request = fetchAxios.get('/', { + signal: controller.signal, + env: { fetch: safariFetch }, + }); + + controller.abort(); + + await assert.rejects( + () => request, + (err) => { + assert.strictEqual(err.name, 'CanceledError'); + assert.strictEqual(err.code, 'ERR_CANCELED'); + assert.strictEqual(axios.isCancel(err), true); + return true; + } + ); + }); + + it('should surface ETIMEDOUT when fetch rejects with a broken DOMException on abort (Safari)', async () => { + const safariFetch = (url, init) => { + const signal = getFetchSignal(url, init); + + return new Promise((_resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(createBrokenDOMExceptionLikeError()); + }; + + if (signal.aborted) return onAbort(); + signal.addEventListener('abort', onAbort); + }); + }; + + await assert.rejects( + () => + fetchAxios.get('/', { + timeout: 50, + env: { fetch: safariFetch }, + }), + (err) => { + assert.strictEqual(err.name, 'AxiosError'); + assert.strictEqual(err.code, 'ETIMEDOUT'); + assert.match(err.message, /timeout of 50ms exceeded/); + return true; + } + ); + }); + }); + it('should combine baseURL and url', async () => { const server = await startHTTPServer(async (req, res) => res.end('OK'), { port: SERVER_PORT }); try {