DEV Community

Cover image for Label Promise.any() inputs so AggregateError.errors drive retries not mysteries
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

Label Promise.any() inputs so AggregateError.errors drive retries not mysteries

If you race multiple mirrors, caches, or endpoints with Promise.any(), the all-fail case often trips teams: the catch block receives an AggregateError and the code treats the failure as a single anonymous incident. Promise.any() actually gives you what you need to make that failure actionable: an AggregateError whose errors array preserves the input order.

Implementation approach (no code here — the server will insert the verified example): label each source before you pass it into Promise.any(). Convert rejections into Error objects that carry a stable sourceLabel and the originalReason. When Promise.any() rejects, inspect aggregate.errors in input order and classify each entry with a predicate that answers “retryable or fatal?”.

Expected behavior: when at least one source fulfills, Promise.any() resolves with that value. When every input rejects, the promise rejects with an AggregateError whose errors array contains the labeled rejection objects in the same order you supplied them. Your classifier returns two lists (retryable and fatal), each entry containing the source label and the original reason so downstream code can retry a single source, skip it, or surface a specific fatal incident.

Limitations and operational notes: this pattern does not cancel remaining work; you must manage cleanup and cost of background tasks. The errors array is ordered by input position, not by completion time, so it is a structural report rather than a timeline. An empty iterable also rejects, so treat it as a configuration or fallback decision rather than a special success path. The fixture provided in the article focuses on the contract boundary: ordering, label preservation, and the availability of original reasons — it does not implement backoff, health scoring, or cancellation.

Why this matters: labeling before aggregation turns a mystery AggregateError into a deterministic decision list. That lets production handlers do targeted retries and avoid masking fatal source problems behind a generic catch-all.

Working example

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;
}
Enter fullscreen mode Exit fullscreen mode

Read the full article: https://chriseugenerodriguez.com/blog/promise-any-aggregateerror-actionable-failures

Top comments (0)