Menu
日本語

JavaScript非同期のエラー処理:async/awaitとtry/catch

async/awaitのtry/catch、Promiseの.catch、そしてエラーを握りつぶしてしまいがちな落とし穴まで。非同期JavaScriptでエラーがどう流れるかを実例で整理します。

非同期コードのエラーは同期処理とは別物

同期的な JavaScript では、投げられたエラーはコールスタックを上に遡り、どこかの try/catch が受け止めるか、受け止められなければプログラムがクラッシュします。ところが非同期コードではこのモデルが成り立ちません。ネットワークリクエストが失敗した頃には、それを開始した関数はとっくに return してしまっていて、遡るべきコールスタックがもう存在しないのです。

Promise はこの問題を、エラー専用のチャネルを用意することで解決しています。Promise は値とともに fulfill するか、理由とともに reject するかのどちらかです。この reject が、非同期版の「throw」にあたります。このページで扱うのはつまり、reject されたエラーがどこかへ消えてしまわないように、必ず自分の管理下で受け止める仕組みを作るという話です。

index.js
Output
Click Run to see the output here.

try/catch はきれいに実行されて抜けていきます。ところが reject が発生するのは 50ms 後で、すでに try ブロックは終わっています。つまり誰もキャッチしてくれない。これがハマりどころです。

await を付ければ try/catch が効く

Promise を await した瞬間、reject は async 関数の中で投げられた例外として扱われます。こうなれば、同期的な throw と同じように外側の try/catch で拾えます。

index.js
Output
Click Run to see the output here.

まず最初に身につけたいのがこのパターンです。await を使えば、非同期の世界をおなじみの try/catch の形に戻せます。失敗する可能性のある await の呼び出しを try の中に置き、catch で処理する。これだけです。

ひとつ覚えておきたいのは、カバーされるのは await を付けた呼び出しだけ、ということ。await せずに Promise を投げっぱなしにすると、エラーはすり抜けていきます。

よくあるバグ: await を忘れる

async 関数を await 無し(あるいは Promise を return せず)に呼ぶと、その rejection は周囲の try/catch をすり抜けてしまいます。

index.js
Output
Click Run to see the output here.

try ブロック自体は問題なく抜けてしまいます。Promise の reject はその次のティックで起きるので、もう捕まえる場所がありません。コンソールには "unhandled promise rejection" の警告が出てきます。

直し方はいつも同じです。呼び出しに await を付けるか、Promise を return して呼び出し元に await させる。これだけです。

index.js
Output
Click Run to see the output here.

.catch() は同じコインの裏側

async/await を使わなくても、.catch() をチェーンすれば rejection を処理できます。

index.js
Output
Click Run to see the output here.

.catch(fn).then(undefined, fn) のショートハンドで、チェーンのそれ以前で発生した reject をまとめて捕まえてくれます。チェーンの末尾に置く .catch() は、いわば非同期版のトップレベル try/catch です。ここで拾い損ねると rejection は「unhandled」扱いになるので、最後の砦として機能します。

この2つのスタイルは混ぜて使って問題ありません。よくあるパターンとしては、関数の内部では async/await を使っておき、呼び出し側で .catch() を付けてもらう形です。

index.js
Output
Click Run to see the output here.

fetch は HTTP エラーで reject されない

これは誰もが一度はハマるポイントです。fetch が reject するのは、ネットワークレベルの失敗が起きたときだけ。つまり DNS の解決に失敗した、接続を拒否された、リクエストが中断された、といったケースです。404 や 500 が返ってきても、fetch としては「成功」扱いになります。Promise は普通に resolve され、ただその response の okfalse になっているだけ、というわけです。

index.js
Output
Click Run to see the output here.

HTTP エラーも catch ブロックで拾いたいなら、res.ok をチェックして明示的に throw しましょう。

index.js
Output
Click Run to see the output here.

同じコードを2回書いていると気づいた時点で、ヘルパー関数に切り出す価値のあるボイラープレートです。

Promise.all は fail-fast、Promise.allSettled はそうじゃない

Promise.all は Promise の配列を受け取り、結果を配列にまとめて resolve してくれます。ただし どれか1つでも reject すると、その時点でエラーとともに即座に reject します。残りの Promise は裏で走り続けますが、結果は捨てられてしまいます。

index.js
Output
Click Run to see the output here.

Fail-fast が正しいのは、すべての結果が必要で、ひとつでも失敗すれば処理全体が無意味になるケースです。一方で、「5つのアップロードを試して、どれが成功してどれが失敗したか教えて」のように、成否にかかわらず全部の結果が欲しい場合は Promise.allSettled を使いましょう。

index.js
Output
Click Run to see the output here.

allSettled は決してrejectされません。戻ってくる各要素は {status: "fulfilled", value}{status: "rejected", reason} のどちらかです。

エラーを絞って catch、想定外は再スロー

すべてのエラーを同じハンドラで処理すべきではありません。よくあるパターンは、いったん catch して中身を確認し、想定外のものはそのまま再スローするやり方です。

index.js
Output
Click Run to see the output here.

すべてのエラーを catch (err) {} の空ブロックで握りつぶしてしまうと、本当のバグが隠れてしまいます。意味のある対処ができるものだけキャッチして、それ以外は再 throw しましょう。

unhandledrejection は最後のセーフティネット

どれだけ気をつけてコードを書いていても、いつかは取りこぼしが発生します。Node.js とブラウザのどちらにも、誰もキャッチしなかった Promise の rejection を拾うためのグローバルフックが用意されています。

// ブラウザ
window.addEventListener("unhandledrejection", event => {
    console.error("未処理:", event.reason);
    event.preventDefault(); // デフォルトのコンソール警告を抑制する
});

// Node.js
process.on("unhandledRejection", reason => {
    console.error("未処理:", reason);
});

これは本来のエラー処理の代替ではなく、あくまで最後の砦としてのログ出力やテレメトリ用のフックです。最近の Node.js では、未処理の rejection が発生するとデフォルトでプロセスがクラッシュします。本番環境ではむしろこの挙動が望ましいことがほとんどです。エラーをログに残し、プロセスを落として、クリーンな状態で再起動させましょう。

実践チェックリスト

失敗する可能性のある処理に触れる async 関数を書くときは、次の点を自問してみてください。

  • リスクのある await はすべて try/catch の中にあるか? あるいは呼び出し側で .catch() によって処理されているか?
  • そもそもちゃんと await しているか? うっかり fire-and-forget(投げっぱなし)になっていないか?
  • fetch なら、レスポンスを信頼する前に res.ok を確認しているか?
  • 並列実行するとき、Promise.all で本当にいいのか? それとも Promise.allSettled のほうが合っているか?
  • トップレベルの .catch()unhandledrejection ハンドラを用意して、エラーが黙って消えないようにしているか?

この5つを押さえておけば、エラーがイベントループの中にスッと吸い込まれて消える、といった不可解な挙動に悩まされることはなくなります。

次は ES Modules

これで非同期処理の章はエラーハンドリングまでひと通り押さえられました。次は JavaScript のコードをファイルをまたいでどう構成するか、つまり importexport、そして現代のあらゆるプロジェクトを支えるモジュールシステムを見ていきます。

よくある質問

async関数の中でエラーはどう処理すればいい?

awaitしている箇所を try/catch で囲むのが基本です。awaitしたPromiseがrejectされると、その拒否理由がthrowされた例外として catch に飛んできます。呼び出し側で処理したい場合は、返ってきたPromiseに .catch() を付ける形でもOKです。

try/catchで囲んでいるのにエラーが捕まらないのはなぜ?

だいたいの原因は、エラーが発生しているコードを await していないことです。async関数を await せず(またはそのPromiseをreturnせず)に呼び出すと、rejectionは外側の try/catch をすり抜けていきます。エラーを拾いたいPromiseは必ず await するか return しましょう。

Promiseがrejectされたのに誰もキャッチしなかったらどうなる?

いわゆるunhandled rejectionになります。Node.jsでは unhandledRejection イベントが発火し、最近のバージョンではデフォルトでプロセスがクラッシュします。ブラウザでは window.onunhandledrejection が発火してコンソールに警告が出ます。いずれにしても .catch() を付けるか、awaittry/catch で囲んで確実に処理してください。

Promise.allでエラーが起きるとどうなる?

Promise.all は渡したPromiseのうち1つでもrejectされた瞬間にreject状態になります。他のPromiseは裏で動き続けますが、結果は捨てられます。成功・失敗すべての結果が欲しい場合は Promise.allSettled を使いましょう。{status, value} または {status, reason} の配列でまとめて返してくれます。

Coddyでコードを学ぼう

始める