🐱

JSで非同期処理のエラーを握りつぶしたい時の書き方まとめ

2024/01/08に公開

皆様こんにちは。

先日、非同期処理においてエラーを握りつぶす処理を書いている中以下のようなコードを書きました。

try {
  asyncErrorRaiseFunction();
} catch (error) {
  console.log("catchの中だよ");
}

この処理を実行したところ以下のエラーが出て悩まされました。

node:internal/process/promises:279
            triggerUncaughtException(err, true /* fromPromise */);
            ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "XXXX".] {
  code: 'ERR_UNHANDLED_REJECTION'
}

Node.js v17.8.0

エラーメッセージからPromiseのRejectが正しくを扱えてなそうなのは読み取れますが、具体的にどうすればいいかわからず詰まってしまいました。
改めて考えるとエラーの扱いの書き方を整理したことがなかったので覚書として記事にしました。

私の環境の都合でnode17で実行していますが、jsでも以下のようなエラーが出るのでjs書きの方なら参考にしていただけると思います。

VM134:4 Uncaught (in promise) XXXXX

検証

今回は以下のawaitの有無とtry-catch構文もしくはPromise chainのchachの組み合わせの4通りを検証します。

try-catch構文 Promise chainのcatch
awaitあり
awaitなし

コードで書くと以下のようになります。
表の数字と関数名の数字を一致させています。

// sample.js

// 1秒後にエラーを出す非同期処理
const asyncErrorRaiseFunction = () =>
  new Promise((_, reject) => {
    setTimeout(() => {
      reject("エラーだよ");
    }, 1000);
  }
);

// awaitあり、try-catch構文
const exIgnoreError1 = async () => {
  try {
    await asyncErrorRaiseFunction();
  } catch (error) {
    console.log("catchの中だよ");
  }
};

// awaitあり、Promise chainのcatch
const exIgnoreError2 = async () => {
  await asyncErrorRaiseFunction().catch(() => {
    console.log("catchの中だよ");
  })
};

// awaitなし、try-catch
const exIgnoreError3 = async () => {
  try {
    asyncErrorRaiseFunction();
  } catch (error) {
    console.log("catchの中だよ");
  }
};

// awaitなし、Promise chainのcatch
const exIgnoreError4 = async () => {
  asyncErrorRaiseFunction().catch(() => {
    console.log("catchの中だよ");
  });
};

// 上記の関数を以下の処理で実行していきます。
new Promise(async () => {
  console.log(`!!! start !!!`);
  const startTime = Date.now();

  // 実行する関数のコメントアウトを外す
  // const ex = exIgnoreError1;
  // const ex = exIgnoreError2;
  // const ex = exIgnoreError3;
  // const ex = exIgnoreError4;

  await ex().catch((error) => {
    console.log('errorがthrowされた');
    console.log(error);
  })

  const endTime = Date.now();
  console.log(`!!! end in ${(endTime - startTime)} !!!`);
});

では、早速実行結果を見ていきたいと思います。

① awaitあり、try-catch構文

% node sample.js
!!! start !!!
catchの中だよ
!!! end in 1005 !!!

1秒かかる非同期処理であるasyncErrorRaiseFunctionの実行完了を待った上でエラーを握りつぶせていることがわかります。

② awaitあり、Promise chainのcatch

% node sample.js
!!! start !!!
catchの中だよ
!!! end in 1005 !!!

こちらも①同様にasyncErrorRaiseFunctionの実行完了を待った上でエラーを握りつぶせています。

③ awaitなし、try-catch構文

冒頭に紹介した私が悩まされたコードです。
実行結果は以下の通り。

% node sample.js
!!! start !!!
!!! end in 2 !!!
node:internal/process/promises:279
            triggerUncaughtException(err, true /* fromPromise */);
            ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "エラーだよ".] {
  code: 'ERR_UNHANDLED_REJECTION'
}

Node.js v17.8.0

非同期処理の実行が終わるのを待ったずに次の処理に進めているが、エラーが出ているという状況です。
一応、エラーメッセージ中にreject実行時に設定したメッセージが書かれているものの、初見では何が悪いかわからないエラーが出ています。

④ awaitなし、Promise chainのcatch

% node sample.js
!!! start !!!
!!! end in 2 !!!
catchの中だよ

最後に、Promise chainを用いて非同期処理を待たない場合の挙動です。
非同期処理を待たないで処理を進められている上に、エラーもきっちり握りつぶせています。

まとめ

検証結果をまとめると以下の通りです。

try-catch構文 Promise chainのcatch
awaitあり
非同期処理を待つ
エラーを握りつぶせる

非同期処理を待つ
エラーを握りつぶせる
awaitなし
非同期処理を待たない
エラーを握りつぶせない

非同期処理を待たない
エラーを握りつぶせる

非同期処理を待つ場合はtry-catch構文、Promise chainのcatchのどちらでも動く一方で、非同期処理を待たないユースケースにおいては必ずPromise chainのcatchを使わないと行けないという結果でした。

考察

結局どのようなケースでこのエラーが発生するかを自分なりに整理しました。

nodeでは一連の処理が1スレッドのみで実行されるので、若干誤解を招く図となってますが、そこは悪しからず。。。

図でメイン処理、非同期処理という単語を使っていますが、
メイン処理=exIgnoreErrorX
非同期処理=asyncErrorRaiseFunction
と読み替えてください。

① awaitあり、try-catch構文

awaitをつけて非同期処理を待ち、かつtry-catchで書いた場合は以下の図のようになります。

① awaitあり、try-catch構文

非同期処理が終わるまで、メイン処理が待ってくれています。
非同期処理でエラーが発生し、メイン処理にエラーが伝播してきても、まだメイン処理が生きているので、catch文に入ることができ、エラーを握りつぶせます。

② awaitあり、Promise chainのcatch

② awaitあり、Promise chainのcatch

こちらも同様に非同期処理が終わるまで、メイン処理が待ってくれています。
ただ、① awaitあり、try-catch構文のケースと異なるのは、非同期処理側でエラーを握りつぶしている点です。

メイン処理、非同期処理どちらでエラーを握りつぶしたとしても、非同期処理が終わるのを待っているため得られる結果は同じになります。

③ awaitなし、try-catch構文

問題のUnhandledPromiseRejectionが発生するケースです。

③ awaitなし、try-catch構文

このケースでは、① awaitあり、try-catch構文とは異なり、非同期処理の完了を待たずに、メイン処理は先に進み、処理を完了してしまいます。

結果として、非同期処理でエラーが発生しエラーが伝播してきたタイミングでは、既にメイン処理が終わっていて、catch文に入ることができず、エラーを握りつぶせないという事象が発生します。

また、メイン処理が終わった後にエラーが伝播してくるため、jsとしては正しくハンドリングされていないエラーとして扱われていると思われます。

④ awaitなし、Promise chainのcatch

最後に、非同期処理を待たないかつPromise chainを使った書き方です。

④ awaitなし、Promise chainのcatch

こちらはと同様に非同期処理側でエラーを握りつぶしています。

エラーが発生し非同期処理が終わるタイミングではメイン処理は終わっているものの、メイン処理にはエラーが伝播してこないため、UnhandledPromiseRejectionが出ることなく処理が終了できています。

まとめ

検証や図で示したように、基本的な考え方としては

  • try-catch: 非同期処理を呼び出す側でエラーハンドリングをしている
  • Promise chainのcatch: 非同期処理側でエラーハンドリングをしている

と捉えておけば問題なさそうだとわかりました。
ちゃんと意識しておかないと意図しない挙動を生み出す可能性があるので気をつけたいですね。

改めて振り返ると、エラーハンドリングを書くときは非同期処理を待つコードが大半で、待たないケースをあまり書くことがなかったことで、このエラーにハマってしまったのかと思います。

もう少し言語仕様にも詳しくならないとなぁと感じた出来事でした。

おまけ:エラーを握り潰さずにthrowする場合

今回行ったのはエラーを握りつぶす処理の場合でしたが、握り潰さずにエラーをthrowするとどうなるかも検証しました。
コードは以下の通りです。

エラーを握り潰さずにthrowする場合のコード
const asyncErrorRaiseFunction = () =>
  new Promise((_, reject) => {
    setTimeout(() => {
      reject("エラーだよ");
    }, 1000);
  });

// awaitあり、try-catch構文
const exRaiseError1 = async () => {
  try {
    await asyncErrorRaiseFunction();
  } catch (error) {
    console.log("catchの中だよ");
    throw error;
  }
};

// awaitあり、Promise chainのcatch
const exRaiseError2 = async () => {
  await asyncErrorRaiseFunction().catch((error) => {
    console.log("catchの中だよ");
    throw error;
  })
};

// awaitなし、try-catch構文
const exRaiseError3 = async () => {
  try {
    asyncErrorRaiseFunction();
  } catch (error) {
    console.log("catchの中だよ");
    throw error;
  }
};

// awaitなし、Promise chainのcatch
const exRaiseError4 = async () => {
  asyncErrorRaiseFunction().catch((error) => {
    console.log("catchの中だよ");
    throw error;
  });
};

// 上記の関数を以下の処理で実行していきます。
new Promise(async () => {
  console.log(`!!! start !!!`);
  const startTime = Date.now();

  // 実行する関数のコメントアウトを外す
  // const ex = exRaiseError1;
  // const ex = exRaiseError2;
  // const ex = exRaiseError3;
  // const ex = exRaiseError4;
  
  await ex().catch((error) => {
    console.log('errorがthrowされた');
    console.log(error);
  })

  const endTime = Date.now();
  console.log(`!!! end in ${(endTime - startTime)} !!!`);
});

①と②の結果は以下のようになりました。

% node sample.js
!!! start !!!
catchの中だよ
errorがthrowされた
エラーだよ
!!! end in 1009 !!!

また、③と④の結果は以下の通りです。

% node sample.js
!!! start !!!
!!! end in 1 !!!
node:internal/process/promises:279
            triggerUncaughtException(err, true /* fromPromise */);
            ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "エラーだよ".] {
  code: 'ERR_UNHANDLED_REJECTION'
}

Node.js v17.8.0

表に書き起こすと以下のようになりました。

try-catch構文 Promise chainのcatch
awaitあり
非同期処理を待つ
throwされたエラーが出る

非同期処理を待つ
throwされたエラーが出る
awaitなし
非同期処理を待たない
UnhandledPromiseRejectionが出る

非同期処理を待たない
UnhandledPromiseRejectionが出る

前述のエラーを握りつぶすケースにおいては問題がなかった④ awaitなし、Promise chainのcatchでもUnhandledPromiseRejectionが出てしまいました。
これは、Promise chainのcatch文でエラーがthrowされることで、メイン処理にエラーが伝わったことによって発生したと思われます。

この検証結果を見る限り、非同期処理を待たない場合、どのような書き方をしても適切なエラーハンドリングはできなさそうですね。
やはり非同期処理は難しい。。。

Discussion