Skip to content
R.

How should I surface and handle AggregateError.errors when Promise.any() rejects so failures are actionable?

Label Promise.any() inputs before you call it, then inspect AggregateError.errors in input order so each failure can drive a retry, fallback, or fatal decision.

About 14 min readComments

Promise.any() is easy to read as “take the first success and ignore the rest.” That is true until every candidate fails. Then the interesting part is not the rejection itself, but the shape of the rejection: an AggregateError with an errors array in the same order as the inputs. That gives you enough structure to recover which source failed, why it failed, and what kind of follow-up is sensible.

This article uses a small labeled-fixture pattern to turn that batch failure into something operational. The goal is not to change Promise.any(), but to keep the source identity visible before the promises are combined so a catch block can separate retryable causes from fatal ones. The result is a deterministic way to treat fallback failures as a decision list instead of a mystery exception.

Promise.any() is a success-first combinator, so a full failure is a batch failure

Promise.any() is often introduced as the promise combinator that returns the first fulfillment value. That mental model is correct, but incomplete in the exact case that causes trouble in production: every candidate source fails. In that situation, the operation does not expose one “real” error and discard the rest. It rejects with an AggregateError that carries the full set of rejection reasons. The important consequence is that the failure is not about one promise winning or losing a race. It is about a batch of sources all being unsuitable, and the caller now owns the choice of what to do next.

That distinction matters when Promise.any() fronts multiple mirrors, caches, endpoints, or fallback providers. If the caller treats the rejection as a single-source failure, the handling code can misclassify the incident. A transient timeout on one source may deserve a retry, while an authentication failure or a malformed response may deserve an immediate fatal path. AggregateError exists precisely because the runtime had several unrelated failures to report, and the caller needs access to the set, not just the existence of rejection.

MDN documents both parts of that contract: Promise.any() fulfills with the first success, and if all inputs reject, the returned promise rejects with an AggregateError whose errors array contains the rejection reasons in input order. That ordering guarantee is the piece that makes deterministic handling possible. Once the sources are labeled before aggregation, the rejection becomes structured enough to support retry and fallback decisions instead of a generic catch-all branch.

  • When every input rejects, Promise.any() does not surface one special loser; it rejects with an AggregateError.
  • The useful part is the errors array, which keeps the same order as the input promises, not the order in which they failed.
  • If you label each source before calling Promise.any(), the batch failure becomes a decision list instead of a mystery exception.
main.mjs / javascript
export function raceAny(labeledPromises) {
  // labeledPromises: Array of { label: string, promise: Promise }
  // Returns a Promise that behaves like Promise.any but preserves labels
  // on per-source rejections by rethrowing labeled Error objects.
  if (!Array.isArray(labeledPromises)) {
    throw new TypeError('expected an array of {label,promise}');
  }

  const wrapped = labeledPromises.map(({ label, promise }) => {
    if (typeof label !== 'string') throw new TypeError('label must be a string');
    // Ensure we treat non-Promise values as fulfilled promises (like Promise.any does)
    const p = Promise.resolve(promise);
    return p.catch((reason) => {
      // Wrap rejection so AggregateError.errors has an inspectable object.
      // Keep the original reason on `.originalReason` for callers that need it.
      const err = new Error(String(reason === undefined ? 'undefined' : reason));
      // stable label and original reason preserved as properties (non-enumerable like typical Error props)
      Object.defineProperty(err, 'sourceLabel', { value: label, enumerable: true });
      Object.defineProperty(err, 'originalReason', { value: reason, enumerable: true });
      // rethrow so Promise.any receives this labeled Error as the rejection reason
      throw err;
    });
  });

  return Promise.any(wrapped);
}

export function classifyAggregateError(aggregateError, isRetryable) {
  // Given an AggregateError from Promise.any when all inputs reject,
  // return { retryable: [...], fatal: [...] } arrays containing objects
  // { label, reason } in the same order as aggregateError.errors.
  if (!(aggregateError instanceof AggregateError)) {
    throw new TypeError('expected an AggregateError');
  }
  if (typeof isRetryable !== 'function') {
    throw new TypeError('isRetryable must be a function');
  }

  const result = { retryable: [], fatal: [] };
  for (const err of aggregateError.errors) {
    // We expect the wrapper in raceAny to attach sourceLabel and originalReason.
    const label = err && typeof err.sourceLabel === 'string' ? err.sourceLabel : '<unknown>';
    const reason = err && Object.prototype.hasOwnProperty.call(err, 'originalReason') ? err.originalReason : err;
    const entry = { label, reason };
    try {
      if (isRetryable(entry)) result.retryable.push(entry);
      else result.fatal.push(entry);
    } catch (_e) {
      // If classifier throws, treat as fatal to be conservative
      result.fatal.push(entry);
    }
  }
  return result;
}

SourcesMDN: Promise.any() (opens a new tab)MDN: AggregateError (opens a new tab)

Attach the label before Promise.any() so the failure list remains readable

The simplest way to make an AggregateError actionable is to attach metadata before the promises go into Promise.any(). The supplied fixture does this by wrapping each source in an object with a stable label, then converting each rejection into an Error that carries two pieces of information: the sourceLabel and the originalReason. That wrapper is not trying to change Promise.any() itself. It is making the rejection reasons inspectable in the same shape they will later have in the AggregateError.errors array.

This is a better boundary than trying to infer the source after the fact. Without labels, a rejection reason may tell you that a network request failed or that a server returned a bad payload, but it will not reliably tell you which origin, mirror, or fallback route produced that result. If all you have is the rejection reason, your handler has to guess. If you preserve labels alongside reasons, the handler can make an explicit decision: retry the labeled source, skip it, or report that specific source as fatal.

The practical rule is simple: label the source at the point where you still know what it is. Do not wait until the catch block and hope the error text identifies the origin. Promise.any() gives you ordered reasons, but it does not invent domain labels for you. Your code must supply those labels if you want the later error handling to stay deterministic and understandable.

  • The runtime preserves the order of the input promises in AggregateError.errors, even if the promises settle in a different order.
  • That means you can attach a stable label to each input before aggregation and recover that label from the error list later.
  • A deterministic test should assert the input order, the labels, and the original reasons separately.

SourcesMDN: Promise.any() (opens a new tab)MDN: AggregateError (opens a new tab)

A deterministic test should prove ordering, labeling, and the rejection shape

A good regression test for this pattern needs to prove more than “the promise rejects.” It has to prove that the failure shape is the one the application logic expects. In the fixture, three labeled sources reject for different reasons. One uses an Error instance, one uses a string, and one uses a plain object. That mix is deliberate. It shows that the handler cannot rely on one narrow error type and that the label metadata survives even when the reason itself is not an Error object.

The key assertion is that Promise.any() throws an AggregateError when all inputs fail and that the AggregateError.errors array preserves the original input order. That is the ordering guarantee the application uses to map the failure back to the labeled source. If the order were unstable, a decision table built from the aggregated reasons would become unreliable. By checking the labels and the reasons independently, the test guards against two different mistakes: losing source identity and accidentally reordering the batch before handling it.

This kind of test is especially useful because Promise.any() can hide a lot of concurrency noise. One source may fail immediately and another may fail later, but the aggregated error list is not a chronology. It is a source-indexed report. The test should protect that distinction so a future refactor does not accidentally treat completion order as meaning order.

  • The example uses three labeled sources that fail for different reasons, including an Error, a string, and an object.
  • The test asserts that Promise.any() rejects, that the AggregateError has three errors, and that the labels are still aligned with the original inputs.
  • A second assertion checks that the reasons are recoverable in the same order as the inputs, which is the contract the handler depends on.
main.test.mjs / javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { raceAny, classifyAggregateError } from './main.mjs';

test('success: one source fulfills -> raceAny resolves with its value', async () => {
  const sources = [
    { label: 'fast', promise: Promise.resolve('ok-fast') },
    { label: 'slow', promise: new Promise((r) => setTimeout(r, 50, 'ok-slow')) },
    { label: 'never', promise: new Promise(() => {}) },
  ];

  const result = await raceAny(sources);
  assert.equal(result, 'ok-fast');
});

test('all reject -> raceAny rejects with AggregateError whose .errors preserve input order and labels', async () => {
  // Create three rejecting sources with different rejection reasons (including a plain value)
  const s1 = { label: 'A', promise: Promise.reject(new Error('err-A')) };
  const s2 = { label: 'B', promise: Promise.reject('string-reason-B') };
  const s3 = { label: 'C', promise: Promise.reject({ code: 42 }) };

  // Each test should create its own independent fixture
  let caught = null;
  try {
    await raceAny([s1, s2, s3]);
    assert.fail('expected raceAny to reject when all inputs fail');
  } catch (e) {
    caught = e;
  }

  assert.ok(caught instanceof AggregateError, 'expected an AggregateError');
  // Promise.any guarantees errors array is in order of the input promises, regardless of completion timing
  assert.equal(caught.errors.length, 3);

  // Check labels and original reasons preserved by our wrapper
  assert.equal(caught.errors[0].sourceLabel, 'A');
  assert.ok(caught.errors[0].originalReason instanceof Error);
  assert.equal(String(caught.errors[0].originalReason), 'Error: err-A');

  assert.equal(caught.errors[1].sourceLabel, 'B');
  assert.equal(caught.errors[1].originalReason, 'string-reason-B');

  assert.equal(caught.errors[2].sourceLabel, 'C');
  assert.deepEqual(caught.errors[2].originalReason, { code: 42 });
});

test('classifyAggregateError: split retryable vs fatal in deterministic order', async () => {
  // Simulate three failing sources where only some reasons are retryable
  const slowNetworkErr = new Error('ENETUNREACH');
  const notFound = new Error('404 Not Found');
  const serverErr = new Error('500 Internal');

  const sources = [
    { label: 'origin-1', promise: Promise.reject(slowNetworkErr) },
    { label: 'origin-2', promise: Promise.reject(notFound) },
    { label: 'origin-3', promise: Promise.reject(serverErr) },
  ];

  try {
    await raceAny(sources);
    assert.fail('expected all sources to fail');
  } catch (e) {
    assert.ok(e instanceof AggregateError);
    // simple predicate: network-like errors are retryable
    const isRetryable = ({ label, reason }) => {
      // Accept either the original Error or string reasons
      const msg = reason && reason.message ? reason.message : String(reason);
      return /ENET|ETIMEDOUT|ECONN/.test(msg);
    };

    const classified = classifyAggregateError(e, isRetryable);

    // Deterministic: items retain the order of the original inputs
    assert.deepEqual(classified.retryable.map(x => x.label), ['origin-1']);
    assert.deepEqual(classified.fatal.map(x => x.label), ['origin-2', 'origin-3']);

    // And reasons preserved
    assert.equal(classified.retryable[0].reason, slowNetworkErr);
    assert.equal(classified.fatal[0].reason, notFound);
    assert.equal(classified.fatal[1].reason, serverErr);
  }
});

SourcesMDN: Promise.any() (opens a new tab)MDN: AggregateError (opens a new tab)

Classify each source separately so the catch block can choose retry or fallback

Once you have an AggregateError with labeled entries, the next step is not to ask whether the whole operation succeeded or failed. You already know it failed. The real question is which failures are retryable and which failures mean the source should be removed from the current decision path. The fixture’s classifyAggregateError helper turns the AggregateError.errors list into two arrays: retryable and fatal. Each entry keeps the source label and the original reason together so the downstream logic can keep the decision local to a particular source.

That separation is useful because operational reasons differ. A network error, timeout, or connection reset often suggests a retry on the same source or a retry against another source. A not-found or schema mismatch error may indicate that the source is unsuitable, but retrying the same request is unlikely to help. In other words, Promise.any() tells you that no source won the race; your classification logic tells you why each candidate lost and what kind of next step is sensible.

The helper also shows a conservative default. If the retryability predicate itself throws, the source is treated as fatal. That choice avoids replacing one missing decision with another unhandled exception. It is not a universal policy, but it is a practical one: classification should preserve evidence and keep the caller in control. The important part is that the decision is made per source, not at the level of the whole batch.

  • Classify each aggregated entry with a predicate that answers “retryable or fatal?” rather than trying to recover from the entire batch at once.
  • Keep the per-source label with the reason in the classification output so downstream code can act on one source at a time.
  • If the classifier itself fails, treat the source as fatal rather than losing the whole batch to a secondary error.

SourcesMDN: AggregateError (opens a new tab)MDN: Promise.any() (opens a new tab)

The useful pattern has limits, and the tests should keep them visible

Promise.any() is not a general substitute for every fallback workflow. It is designed to return the first fulfillment, which means it is excellent when success is enough and the caller does not need all results. It is not a cancellation primitive, and it is not a monitor for source-side resource cleanup. MDN notes that once Promise.any() is called, the input promises are marked as handled, but the remaining work is not cancelled automatically. If a fallback source keeps running after another source succeeds, the runtime still expects your application to manage that cost.

A second limit is that the errors array is ordered by input position, not by completion time. That is exactly what makes the batch deterministic, but it also means the list is a structural report rather than a chronological trace. If you need to explain the timing of failures, you still need timestamps, logging, or per-source instrumentation. The aggregated error only tells you which source was which and what reason each one produced.

A third edge case is the empty iterable. Promise.any() rejects there too, because no promise can fulfill from an empty list. That is not a special “all failed” case, but it still reaches the same rejection boundary. A robust caller therefore needs to treat the empty input case as part of the same decision surface: there is no winning source, and the application must choose whether to report a configuration problem, rebuild the source list, or fall back to a different strategy altogether.

  • An empty iterable also rejects, so the caller still needs a catch path even when the input list is built dynamically.
  • Promise.any() does not cancel remaining work after the first fulfillment, so source-side cleanup remains your responsibility.
  • Ordered errors help the caller reason about the batch, but they do not tell you whether a source failed before or after another one.

SourcesMDN: Promise.any() (opens a new tab)MDN: AggregateError (opens a new tab)

What the fixture proves, and what it leaves to the application

The supplied fixture is intentionally small because its job is to isolate the contract boundary. It proves that Promise.any() resolves on the first success, rejects when every input fails, and produces an AggregateError that can be inspected through its errors array. It also proves that the label-preserving wrapper keeps source identity visible when the batch fails, which is the part the caller needs in order to decide what happens next.

The fixture does not try to simulate an entire production fallback system. It does not implement cache eviction, retry backoff, circuit breaking, or source health scoring. Those are useful features, but they are separate design decisions. The article’s point is narrower: if you already have several labeled sources and you are racing them with Promise.any(), then the fully failing case should not be treated as a single anonymous exception. It should be interpreted as a structured report with one entry per source.

That is why the article focuses on a deterministic test pattern. The test protects the ordering rule, the presence of the AggregateError, and the availability of the per-source reasons. Once those are guaranteed, the caller can build the next step it actually wants: retry a transient source, fall back to a known-good mirror, or surface a fatal incident with enough detail to be useful. The runtime gives you the batch; your code decides what the batch means.

  • The fixture proves the shape of the rejection and the classification strategy, but it does not prove real network behavior.
  • The tests do not establish cancellation, retry backoff, or source health checks; those remain application responsibilities.
  • The operational value comes from turning a single AggregateError into a labeled set of next actions.

SourcesMDN: Promise.any() (opens a new tab)MDN: AggregateError (opens a new tab)

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed