非同期コードのエラーは同期処理とは別物
同期的な JavaScript では、投げられたエラーはコールスタックを上に遡り、どこかの try/catch が受け止めるか、受け止められなければプログラムがクラッシュします。ところが非同期コードではこのモデルが成り立ちません。ネットワークリクエストが失敗した頃には、それを開始した関数はとっくに return してしまっていて、遡るべきコールスタックがもう存在しないのです。
Promise はこの問題を、エラー専用のチャネルを用意することで解決しています。Promise は値とともに fulfill するか、理由とともに reject するかのどちらかです。この reject が、非同期版の「throw」にあたります。このページで扱うのはつまり、reject されたエラーがどこかへ消えてしまわないように、必ず自分の管理下で受け止める仕組みを作るという話です。
try/catch はきれいに実行されて抜けていきます。ところが reject が発生するのは 50ms 後で、すでに try ブロックは終わっています。つまり誰もキャッチしてくれない。これがハマりどころです。
await を付ければ try/catch が効く
Promise を await した瞬間、reject は async 関数の中で投げられた例外として扱われます。こうなれば、同期的な throw と同じように外側の try/catch で拾えます。
まず最初に身につけたいのがこのパターンです。await を使えば、非同期の世界をおなじみの try/catch の形に戻せます。失敗する可能性のある await の呼び出しを try の中に置き、catch で処理する。これだけです。
ひとつ覚えておきたいのは、カバーされるのは await を付けた呼び出しだけ、ということ。await せずに Promise を投げっぱなしにすると、エラーはすり抜けていきます。
よくあるバグ: await を忘れる
async 関数を await 無し(あるいは Promise を return せず)に呼ぶと、その rejection は周囲の try/catch をすり抜けてしまいます。
try ブロック自体は問題なく抜けてしまいます。Promise の reject はその次のティックで起きるので、もう捕まえる場所がありません。コンソールには "unhandled promise rejection" の警告が出てきます。
直し方はいつも同じです。呼び出しに await を付けるか、Promise を return して呼び出し元に await させる。これだけです。
.catch() は同じコインの裏側
async/await を使わなくても、.catch() をチェーンすれば rejection を処理できます。
.catch(fn) は .then(undefined, fn) のショートハンドで、チェーンのそれ以前で発生した reject をまとめて捕まえてくれます。チェーンの末尾に置く .catch() は、いわば非同期版のトップレベル try/catch です。ここで拾い損ねると rejection は「unhandled」扱いになるので、最後の砦として機能します。
この2つのスタイルは混ぜて使って問題ありません。よくあるパターンとしては、関数の内部では async/await を使っておき、呼び出し側で .catch() を付けてもらう形です。
fetch は HTTP エラーで reject されない
これは誰もが一度はハマるポイントです。fetch が reject するのは、ネットワークレベルの失敗が起きたときだけ。つまり DNS の解決に失敗した、接続を拒否された、リクエストが中断された、といったケースです。404 や 500 が返ってきても、fetch としては「成功」扱いになります。Promise は普通に resolve され、ただその response の ok が false になっているだけ、というわけです。
HTTP エラーも catch ブロックで拾いたいなら、res.ok をチェックして明示的に throw しましょう。
同じコードを2回書いていると気づいた時点で、ヘルパー関数に切り出す価値のあるボイラープレートです。
Promise.all は fail-fast、Promise.allSettled はそうじゃない
Promise.all は Promise の配列を受け取り、結果を配列にまとめて resolve してくれます。ただし どれか1つでも reject すると、その時点でエラーとともに即座に reject します。残りの Promise は裏で走り続けますが、結果は捨てられてしまいます。
Fail-fast が正しいのは、すべての結果が必要で、ひとつでも失敗すれば処理全体が無意味になるケースです。一方で、「5つのアップロードを試して、どれが成功してどれが失敗したか教えて」のように、成否にかかわらず全部の結果が欲しい場合は Promise.allSettled を使いましょう。
allSettled は決してrejectされません。戻ってくる各要素は {status: "fulfilled", value} か {status: "rejected", reason} のどちらかです。
エラーを絞って catch、想定外は再スロー
すべてのエラーを同じハンドラで処理すべきではありません。よくあるパターンは、いったん catch して中身を確認し、想定外のものはそのまま再スローするやり方です。
すべてのエラーを 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 のコードをファイルをまたいでどう構成するか、つまり import、export、そして現代のあらゆるプロジェクトを支えるモジュールシステムを見ていきます。
よくある質問
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() を付けるか、await を try/catch で囲んで確実に処理してください。
Promise.allでエラーが起きるとどうなる?
Promise.all は渡したPromiseのうち1つでもrejectされた瞬間にreject状態になります。他のPromiseは裏で動き続けますが、結果は捨てられます。成功・失敗すべての結果が欲しい場合は Promise.allSettled を使いましょう。{status, value} または {status, reason} の配列でまとめて返してくれます。