並列実行した Promise で throw されても全てハンドルしたいときの方法(allSettled, finally, etc...)

2021/11/11に公開

要諦

以下のアプローチが主な解決方法となるだろう

  • Promise.allSettled で全ての Promise の解決を待ち、解決された値を用いて処理を行う(動機的後処理)
  • Promise.all に渡した各処理に Promise.prototype.{then,catch,finally} を定義し、適切に後処理を行う(非同期的後処理)
  • 各処理を中断可能に実装し、 catch された時点で中断命令を送る(中断)

Thanks to @uhyo_

ハンドルしたいだけなら allSettled である必要はない旨のご指摘を頂いたため、本文趣旨を変更しました。

詳細

  • Promise.all の返却する Promise は何かしら 1 つでも throw された時点で Reject され解決される。
    • このとき他の Promise は中断されない
      • 浮いたまま実行され続けている (fulfilled に 1-22-0 の後に push されていることに注目)
      • 当然 reject もされる (rejected に 1-3 がある)
        • Promise.all の catch 節で取りこぼしている
          • ログ漏れ、復帰処理、切断処理等々が懸念される
  • Promise.allSettled では fulfilled / rejected に関わらず(いくつ throw されても)全ての Promise が解決されるまで待つ
    • 成功、失敗を問わず、Promise.all に渡した Array の順序で結果の Array が返却される。
    • 結果の型は { status: 'fulfilled', value: T } | { status: 'rejected', reason: any } である (ref: typescript/lib/lib.es20xx.promise.d.ts)
      • TypeScript では status の値を判別することで、 value あるいは reason のどちらが付随しているか推論される
    • PromiseSettledResult<T> 型はグローバルから参照できる
    • 残念ながら IE11 には未対応
  • Promise.prototype.{then,catch,finally} を用いることで、 Promise.all でハンドルできなくとも、自身で後処理が行える
    • 特に finally は実行が保証されるため、プロセス終了前の処理にも使用できる

備考

  • Promise.all の各処理内で Promise.reject を実行すると一番上の Promise を reject するので注意が必要
  • Promise.all の各処理内で Promise.resolve に Error Object を渡す手法は Promise.allSettled を独自実装していることとほぼ同様になるため、 Promise.allSettled が推奨されると考えられる

Promise.allSettled でのサンプルコード (リポジトリ)

const { setTimeout } = require('timers/promises')

const values = [true, false, true, false, true]

let started = []
let fulfilled = []
let rejected = []

async function main () {
  console.log('--- 1 started ---')

  const results1 = await Promise.all(values.map(async (v, idx) => {
    started.push(`1-${idx}`)
    await setTimeout(1000 * idx)
    if (v) {
      fulfilled.push(`1-${idx}`)
      return v
    }
    rejected.push(`1-${idx}`)
    throw new Error(`Error at ${idx}`)
  })).catch((e) => {
    // catched only 1 error
    console.error('error1: ', e.toString())
  })
  console.log('result1: ', results1) // <- undefined

  console.log('--- 1 ended ---') // 1000 * 1 sec later

  console.log('--- 2 started ---')

  const results2 = await Promise.allSettled(values.map(async (v, idx) => {
    started.push(`2-${idx}`)
    await setTimeout(1000 * idx)
    if (v) {
      fulfilled.push(`2-${idx}`)
      return v
    }
    rejected.push(`2-${idx}`)
    throw new Error(`Error at ${idx}`)
  }))
  // catched all errors
  console.error('error2: ', results2.filter((r) => r.status === 'rejected').map((r) => r.reason.toString()))
  // all fulfilled results
  console.log('result2: ', results2.filter((r) => r.status === 'fulfilled').map((r) => r.value))

  console.log('--- 2 ended ---') // 1000 * 4 sec later

  console.log('started', started)
  /*
   * started [
   *   '1-0', '1-1', '1-2',
   *   '1-3', '1-4', '2-0',
   *   '2-1', '2-2', '2-3',
   *   '2-4'
   * ]
   */
  console.log('fulfilled', fulfilled)
  /*
   * fulfilled [ '1-0', '2-0', '1-2', '2-2', '1-4', '2-4' ]
   */
  console.log('rejected', rejected)
  /*
   * rejected [ '1-1', '2-1', '1-3', '2-3' ]
   */
}

main().catch((e) => {
  console.error(e)
  process.exitCode = 1
})

Discussion