•12 min read

Promise.raceをフォールバックに使うのはやめよう:Promise.anyとAggregateErrorのすすめ

Promise.raceをフォールバックに使うのはやめよう:Promise.anyとAggregateErrorのすすめ
Promise Combinators

Promise.any

Click to reveal
Promise Combinators
Promiseのイテラブルを受け取り、いずれかの入力が解決(resolve)するとすぐに解決する新しいPromiseを返す静的メソッド。すべての入力が拒否(reject)した場合にのみ拒否される。

Promise.any

Promise Combinators

AggregateError

Click to reveal
Promise Combinators
ES2021で導入された新しいErrorサブクラスで、複数の個別のエラーを単一のerrors配列にまとめる。Promise.anyの内部で使用される。

AggregateError

Promise Combinators

Promise.all

Click to reveal
Promise Combinators
Promiseのイテラブルを受け取り、すべての入力が解決した場合にのみ解決する新しいPromiseを返す静的メソッド。いずれかの入力が拒否されるとすぐに拒否される。

Promise.all

Promise Combinators

Promise.allSettled

Click to reveal
Promise Combinators
すべての入力Promiseが解決(settle)した後に常に解決するPromiseを返す静的メソッド。成功または失敗にかかわらず、結果オブジェクトの配列を提供する。

Promise.allSettled

Promise Combinators

Promise.race

Click to reveal
Promise Combinators
Promiseのイテラブルを受け取り、いずれかの入力が解決(settle)するとすぐに解決するPromiseを返す静的メソッド。解決または拒否のどちらであっても同様。

Promise.race

Promise.anyとAggregateError:必要だと知らなかったパターン

私は数ヶ月間、Promise.anyを無視していました。なぜなら、それは単にPromise.allの反対だと思っていたからです。興味深いけれど、ニッチなものだと。しかし、それは間違いでした。これは、以前はぎこちない回避策が必要だった現実世界の問題群に対する、最もクリーンな解決策です。

Audio Briefing
0:00 / 0:00

コンビネーターの状況

JavaScriptには4つのPromiseコンビネーターがあります。これらはファミリーとして機能します。個別に学ぶよりも、まとめて理解する方が早いです。

コンビネーター解決する条件拒否する条件
Promise.allすべてのPromiseが解決するいずれかのPromiseが拒否する
Promise.allSettledすべてのPromiseが解決する(常に)決して拒否しない — 常に解決する
Promise.raceいずれかのPromiseが解決するいずれかのPromiseが拒否する
Promise.anyいずれかのPromiseが解決するすべてのPromiseが拒否する

Promise.anyは空の象限に位置します。最初の成功で解決し、失敗をショートサーキットするのではなく、集約します。それがこの機能の全てです。

覚え方

any + settledはそれぞれrace + allのように振る舞います。any = 最初の勝利、settled = 全員を待つ。これら4つの名前を覚える代わりに、この2つの軸を組み合わせて考えることができます。

Advertisement

それが置き換える欠陥のあるアプローチ

「フォールバック」の動作が必要なときに、私が以前書いていたコードは次のとおりです。

古いパターン実際に必要だったもの
Aを試行し、失敗したらBを試行し、失敗したらCを試行する
最初の成功で解決 — 逐次的な待機なし、帯域幅の無駄なし
最初の成功にはPromise.anyを使用する
どのフェッチが解決したかを確認し、残りを無視する
AggregateErrorは、その詳細を無料で正確に提供する
どのPromiseが最初に解決したかを手動で検査する必要がある
タイムアウトまたはフォールバックのための`Promise.race`
`Promise.any` — 最初の成功、正直な失敗報告
Raceは成功ではなく解決で勝利する — 失敗を静かに飲み込む

古いアプローチでは、最速のサービスを呼び出し、それが良好であればその結果を使用し、そうでなければフォールバックしていました。Promise.raceは魅力的ですが、競合(race)で静かに失敗します。勝者が成功したのか、単に最初に反応しただけなのかがわかりません。Promise.anyはこれを直接解決します。

基本的な使い方

最小限の例
const urls = [
  "https://api.primary.com/data",
  "https://api.backup-1.com/data",
  "https://api.backup-2.com/data",
];
try {
  const response = await Promise.any(
    urls.map((url) => fetch(url).then((r) => r.json()))
  );
  console.log(
    "Got data from first successful fetch:",
    response
  );
} catch (aggregateError) {
  // All three failed — see exactly why
  console.log(
    "All endpoints failed:",
    aggregateError.errors
  );
  aggregateError.errors.forEach((err, i) => {
    console.log(`Source ${i}:`, err.message);
  });
}

重要な詳細:Promise.anyが拒否された場合、エラーは通常のErrorではなく、AggregateErrorです。これは、入力イテラブルと同じ順序で、失敗した入力ごとに1つのエントリを持つ.errors配列を保持します。

明らかではないユースケース

1. フォールバックを伴う並列機能フラグフェッチ。 3つのフラグプロバイダーを同時に呼び出します。最初に応答したプロバイダーの結果が必要で、すべてのプロバイダーがダウンした場合にのみ失敗を気にします。

2. raceセマンティクスなしのタイムアウトラッパー。 遅い呼び出しをタイムアウトでラップします。

async function fetchWithTimeout(url, ms) {
  return Promise.any([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(
        () => reject(new Error("Timeout")),
        ms
      )
    ),
  ]);
}

これはPromise.raceよりもクリーンです。なぜなら、タイムアウトは拒否のみを消費し、解決は消費しないからです。

3. プログレッシブ画像読み込み。 ブラウザがサポートする最初の形式で解決します。

const sources = [
  fetchImage("webp"),
  fetchImage("avif"),
  fetchImage("png"), // fallback
];
const image = await Promise.any(sources); // Use whichever format loaded first

4. 堅牢な認証トークン更新。 2つのプロバイダーから同時に更新し、成功した方を使用します。

const tokens = await Promise.any([
  refreshGitHubToken(),
  refreshGitLabToken(),
]); // Use tokens regardless of which provider responded
Advertisement

AggregateErrorの詳細

AggregateErrorオブジェクトは、単なる拒否以上のものを明らかにします。

try {
  await Promise.any(failingPromises);
} catch (err) {
  // err is an AggregateError
  console.log(err.name); // "AggregateError"
  console.log(err.message); // "All promises were rejected"
  console.log(err.errors); // [TypeError, ReferenceError, RangeError]
  ...
}

err.errorsは個々のエラーオブジェクトを保持するため、ソースごとにロジックを分岐させることができます。これにより、Promise.anyは「最初の成功」のセマンティクスだけでなく、ロギングやエラー報告に真に役立つものとなります。

並列化のレバー

I/Oを並列で実行することでレイテンシを平坦化することは、JavaScriptの最適化の最も古いトリックです。Promise.allは「すべて成功する必要がある」ケースをカバーしました。Promise.anyは「少なくとも1つが成功する」ケースをカバーします。これはほとんどの現実世界の要求に当てはまります。

比較:

  • 失敗時に再試行する単一のAPI呼び出しは逐次的なオーバーヘッドです。各試行でタイムアウトを待つことになります。
  • Promise.anyを使用して2つのサービスに並列で発行される同じリクエストは、両方のうち最速のものによって時間が制限されます。遅い失敗コストは、より速いタイムアウトに制限されます。

この非対称性は、個々のタイムアウトが長いモバイルや高レイテンシ接続で最も重要になります。

互換性と注意点

  • Node.js 15+、Deno 1.0+、またはすべてのモダンブラウザ(Chrome 85+、Firefox 79+、Safari 14+)が必要です。Node 14では利用できません。
  • すべての入力が拒否された場合、結果のAggregateError.messageは常にリテラル文字列"All promises were rejected"です。デバッグのためにこれを解析せず、代わりに.errorsを使用してください。
  • Promise以外のイテラブルエントリは、他のPromiseメソッドとまったく同じように強制されます(ES2020以降の一貫した動作)。
  • Promise.anyは解決した場合にのみ.thenの呼び出しを停止するため、別の入力がすでに解決した後でも、拒否する入力は拒否されます。追加の拒否は、キャッチされない限り、未処理の拒否警告を生成します。
  • 空のイテラブルはすぐに拒否されます。Promise.any([])はAggregateErrorで拒否され、その.errors配列は空です。これは、Promise.all([])が[]で解決するのとは異なります。

クイックリファレンス

// Basic: first success wins
const winner = await Promise.any([
  fetch(urlA),
  fetch(urlB),
  fetch(urlC),
]);
// Fallback: value from whichever call resolved first
// All fail: catch AggregateError — inspect .errors

// Timeout pattern
async function withTimeout(promise, ms) {
  return Promise.any([
    promise,
    new Promise((_, reject) =>
      setTimeout(
        () => reject(
          new Error(`Timed out after ${ms}ms`)
        ),
        ms
      )
    ),
  ]);
}

// Typed (TypeScript)
function firstSuccessful<T>(
  promises: Array<Promise<T>>
): Promise<T> {
  return Promise.any(promises);
}

こちらもどうぞ

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement