2
0
mirror of https://github.com/tenrok/axios.git synced 2026-06-17 19:21:29 +03:00

fix(fetch): preserve abort reasons in fetch adapter (#10806)

Based on the work in axios/axios#7191.

Co-authored-by: Mostafa-Khairy0 <mostafakhairy0305@gmail.com>
Co-authored-by: Jason Saayman <jasonsaayman@gmail.com>
This commit is contained in:
Yossi Eliaz
2026-04-27 18:42:33 +03:00
committed by GitHub
parent d9b941c302
commit 2344aab609
3 changed files with 131 additions and 3 deletions
+11
View File
@@ -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(
+2 -2
View File
@@ -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<Response>((_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');
});
});
+118 -1
View File
@@ -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 {