Background
lib/helpers/composeSignals.js currently composes multiple AbortSignals and an optional timeout using manually-attached addEventListener('abort', ...) listeners. Modern runtimes (Node 19+, current browsers) provide AbortSignal.any(...) natively, which would let us drop the manual aggregation.
A previous attempt to do this (#7380) regressed behavior, so it was closed in favor of this issue.
The constraint to preserve
Today, every reason that lands on the composed signal is normalized:
const err = reason instanceof Error ? reason : this.reason;
controller.abort(
err instanceof AxiosError
? err
: new CanceledError(err instanceof Error ? err.message : err)
);
So composedSignal.reason is always an AxiosError (since CanceledError extends AxiosError).
The fetch adapter relies on this invariant at lib/adapters/fetch.js:415 to recover from Safari's broken DOMException-like aborts:
if (composedSignal && composedSignal.aborted && composedSignal.reason instanceof AxiosError) {
const canceledError = composedSignal.reason;
// ...
throw canceledError;
}
A naive switch to AbortSignal.any propagates the source signal's reason as-is, breaking this for any user that calls controller.abort() with no reason or with a plain Error.
What needs to happen
Use AbortSignal.any internally as the aggregator, but expose an outer signal whose reason is always an AxiosError/CanceledError. Approximate shape:
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
const outer = new AbortController();
let timer;
const clearTimer = () => { timer && clearTimeout(timer); timer = null; };
const sources = [...signals];
if (timeout) {
const timeoutCtrl = new AbortController();
timer = setTimeout(() => {
timeoutCtrl.abort(new AxiosError(`timeout of ${timeout}ms exceeded`, AxiosError.ETIMEDOUT));
}, timeout);
sources.push(timeoutCtrl.signal);
}
const inner = AbortSignal.any(sources);
inner.addEventListener('abort', () => {
if (outer.signal.aborted) return;
const r = inner.reason;
outer.abort(
r instanceof AxiosError
? r
: new CanceledError(r instanceof Error ? r.message : r)
);
});
outer.signal.unsubscribe = () => utils.asap(clearTimer);
return outer.signal;
}
// existing fallback below
Keep the existing implementation as the fallback for runtimes without AbortSignal.any.
Acceptance criteria
Context
Background
lib/helpers/composeSignals.jscurrently composes multipleAbortSignals and an optional timeout using manually-attachedaddEventListener('abort', ...)listeners. Modern runtimes (Node 19+, current browsers) provideAbortSignal.any(...)natively, which would let us drop the manual aggregation.A previous attempt to do this (#7380) regressed behavior, so it was closed in favor of this issue.
The constraint to preserve
Today, every reason that lands on the composed signal is normalized:
So
composedSignal.reasonis always anAxiosError(sinceCanceledErrorextendsAxiosError).The fetch adapter relies on this invariant at
lib/adapters/fetch.js:415to recover from Safari's broken DOMException-like aborts:A naive switch to
AbortSignal.anypropagates the source signal's reason as-is, breaking this for any user that callscontroller.abort()with no reason or with a plainError.What needs to happen
Use
AbortSignal.anyinternally as the aggregator, but expose an outer signal whose reason is always anAxiosError/CanceledError. Approximate shape:Keep the existing implementation as the fallback for runtimes without
AbortSignal.any.Acceptance criteria
AbortSignal.anywhen availablecomposedSignal.reason instanceof AxiosErrorholds in every abort scenario, including:controller.abort()with no reasoncontroller.abort(new Error('x'))controller.abort('string reason')controller.abort(new AxiosError(...))composedSignal.unsubscribeis exposed and clears the timeout timertests/unit/composeSignals.test.jscovering the fourcontroller.abort(...)shapes above, assertingsignal.reason instanceof AxiosErrorfetch.js:415) still triggers under user-initiated cancellationContext
lib/helpers/composeSignals.jslib/adapters/fetch.js