Skip to content

Modernize composeSignals with AbortSignal.any while preserving reason normalization #10815

@jasonsaayman

Description

@jasonsaayman

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

  • Fast path uses AbortSignal.any when available
  • Fallback remains for runtimes without it
  • composedSignal.reason instanceof AxiosError holds in every abort scenario, including:
    • timeout fires
    • controller.abort() with no reason
    • controller.abort(new Error('x'))
    • controller.abort('string reason')
    • controller.abort(new AxiosError(...))
    • cancelToken-derived signal
  • composedSignal.unsubscribe is exposed and clears the timeout timer
  • Regression test in tests/unit/composeSignals.test.js covering the four controller.abort(...) shapes above, asserting signal.reason instanceof AxiosError
  • Fetch-adapter integration test confirming the Safari catch-block path (fetch.js:415) still triggers under user-initiated cancellation

Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions