mirror of
https://github.com/tenrok/axios.git
synced 2026-06-20 20:00:40 +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:
@@ -409,6 +409,17 @@ const factory = (env) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
unsubscribe && unsubscribe();
|
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)) {
|
if (err && err.name === 'TypeError' && /Load failed|fetch/i.test(err.message)) {
|
||||||
throw Object.assign(
|
throw Object.assign(
|
||||||
new AxiosError(
|
new AxiosError(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const createAbortedError = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('timeout', () => {
|
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) =>
|
const fetch = (input: unknown, init?: RequestInit) =>
|
||||||
new Promise<Response>((_resolve, reject) => {
|
new Promise<Response>((_resolve, reject) => {
|
||||||
const signal = init?.signal || (input instanceof Request ? input.signal : undefined);
|
const signal = init?.signal || (input instanceof Request ? input.signal : undefined);
|
||||||
@@ -45,6 +45,6 @@ describe('timeout', () => {
|
|||||||
.catch((e: any) => e);
|
.catch((e: any) => e);
|
||||||
|
|
||||||
expect(axios.isAxiosError(err)).toBe(true);
|
expect(axios.isAxiosError(err)).toBe(true);
|
||||||
expect(err.code).toBe('ECONNABORTED');
|
expect(err.code).toBe('ETIMEDOUT');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ const fetchAxios = axios.create({
|
|||||||
adapter: 'fetch',
|
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', () => {
|
describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => {
|
||||||
it('should sanitize request headers containing CRLF characters', async () => {
|
it('should sanitize request headers containing CRLF characters', async () => {
|
||||||
const server = await startHTTPServer(
|
const server = await startHTTPServer(
|
||||||
@@ -470,7 +493,7 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
|
|||||||
await setTimeoutAsync(1000);
|
await setTimeoutAsync(1000);
|
||||||
res.end('OK');
|
res.end('OK');
|
||||||
},
|
},
|
||||||
{ port: SERVER_PORT }
|
{ port: 0 }
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
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 () => {
|
it('should combine baseURL and url', async () => {
|
||||||
const server = await startHTTPServer(async (req, res) => res.end('OK'), { port: SERVER_PORT });
|
const server = await startHTTPServer(async (req, res) => res.end('OK'), { port: SERVER_PORT });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user