Prerequisites: familiarity with Promise.any(), AggregateError, and a simple retry vs fatal policy for network/service errors.
Implementation decision: wrap each candidate source with a label before calling Promise.any(). Convert rejections into Error objects that include a stable sourceLabel and preserve originalReason. Call Promise.any() over the wrapped promises. If it rejects with an AggregateError, iterate aggregate.errors in the same input order and run a predicate per-entry that determines whether the failure is retryable or should be treated as fatal. Keep the label and the reason paired so downstream code can act against a single source.
Expected result: when any source fulfills, you get that value. When every source rejects, you get an AggregateError whose errors array contains the labeled rejection objects in the order the sources were provided. The classification step returns two lists (retryable and fatal) that preserve input order so the caller can retry specific sources or remove them from the decision set.
One limitation: this approach does not cancel in-flight work after a decision. Promise.any() does not stop other promises from continuing; you must implement cleanup or cancellation at the source. Also remember the errors array is not chronological — it is positional — so add timestamps or logging if you need a timeline.
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;
}
Read the full article: https://chriseugenerodriguez.com/blog/promise-any-aggregateerror-actionable-failures