Node.jsでUnhandled Rejectionsが発生してprocessが終了すると、Exit Statusが0となる問題とその対策についてのメモです。

事前知識: Async FunctionはPromiseを返す関数定義です。 その辺について詳しくは次のサイトを読んでください。

今回のサンプルコードは次のリポジトリにあります。

Unhandled RejectionsとNode.jsの終了ステータス

次のmain.jsはPromise内で例外が投げていますが、呼び出し元のmain()は何もキャッチしていません。 そのため、Unhandled Rejectionsが発生しています。

main.js:

function waitFor(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

async function main(shouldFailed) {
    await waitFor(1000);
    throw new Error("ERRRRRRR!"); // failed after 1sec
}

main();

ブラウザではUnhandled Rejectionsは自動的にコンソールエラーとして表示されるためあまり問題には感じないかもしれません。

Unhandled Rejections on Browser

Node.jsではこのコードを実行するとUnhandled Rejectionsとなりエラー表示はでますが、その際のExit Statusは0であるため正常終了となります。

$ node main.js
(node:12236) UnhandledPromiseRejectionWarning: Error: ERRRRRRR!
    at main (/unhandled-reject-example/main.js:9:11)
(node:12236) UnhandledPromiseRejectionWarning: Unhandled promise rejection. 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(). (rejection id: 1)
(node:12236) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
$ echo $?
0

DEP0018: Unhandled promise rejectionsの将来的にexit statusは0ではなくすというDeprecationは出ていますが、Node 12時点でもデフォルトのexit statusが0のままです。

Unhandled Rejectionsでプロセスが終了すると正常終了とみなされて、Exit Statusを見るツールがうまく動かないことがあります。 そのため、必ずなにかしらの方法でPromise/Async Functionの例外はキャッチすることが重要です。

Promise#catchで明示的にエラー終了する

先ほどのコードを簡単に修正するなら、main()の呼び出しに対してちゃんとPromise#catchでエラー時の処理を書くことです。

fixed-main.js:

function waitFor(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

async function main(shouldFailed) {
    await waitFor(1000);
    throw new Error("ERRRRRRR!"); // failed after 1sec
}

// Random Success or failure
main().then(() => {
    console.log("ok");
    // It is optional - if comment out is, node.js get same result
    process.exit(0); 
}).catch(error => {
    console.error(error);
    process.exit(1);
});

この場合は、main()関数でエラーが発生(Async Function内でエラーをthrowするとRejectedなPromiseを返す)した場合にcatchメソッドのコールバックが呼び出されます。 catch内でprocess.exit(1)を使い明示的にExit Statusを1にしてプロセスを終了させているため安全です。

$ node fixed-main.js
echo Error: ERRRRRRR!
    at main (/unhandled-reject-example/fixed-main.js:9:11)
$ echo $?
1

基本的にアプリケーションのエントリポイント呼び出しではエラーが起きた場合の処理は書いておくのが良いでしょう。

unhandledRejectionイベントで明示的にエラー終了する

Node.jsではprocess.on("unhandledRejection", (reason, promise) => {})というイベントをサポートしています。

これは、Unhandled Rejectionsが発生した際に呼ばれるコールバック関数を登録できます。 このunhandledRejectionイベントを使えば、Unhandled Rejectionsが発生した際にProcessをエラー終了させることもできます。

fixed-unhandledRejection-event-main.js:

process.on('unhandledRejection', (reason, promise) => {
  console.error(reason);
  process.exit(1);
});

function waitFor(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

async function main(shouldFailed) {
  await waitFor(1000);
  throw new Error("ERRRRRRR!"); // failed after 1sec
}

main();

このコードを実行してみると exit status が 1 となることがわかります。

$ node fixed-unhandledRejection-event-main.js
Error: ERRRRRRR!
    at main (/fixed-unhandledRejection-event-main.js:14:9)
$ echo $?
1

これと同様のことをするモジュールはいくつか存在します。

まずはPromise#catchで管理することが重要ですが、書き忘れた際にUnhandled Rejectionsが発生してしまうので、それを防止する役割として利用できます。

似た問題として、テストフレームワークのMochaはデフォルトではUnhandled Rejectionsが発生してもテストを成功として扱ってしまいます。

そのため、次のようなテストケースはMochaではパスしてしまいます。

async function fail() {
    throw new Error("FAIL!!");
}

it("unhandled rejection", () => {
    fail();
});

これもunhandledRejectionを使って例外を投げるようにすることで、テストケースを失敗させられます。

process.on("unhandledRejection", reason => {
    throw reason;
});

async function fail() {
    throw new Error("FAIL!!");
}

it("unhandled rejection, but throw it", () => {
    fail();
});

Mochaで実行すると、少なくてもテストは通らなくなって安全です。(ただし結果の表示はおかしいときがあります)

$ mocha *.js



  ✓ unhandled rejection
  1) unhandled rejection, but throw it

  1 passing (4ms)
  1 failing

  1) is unhandled rejection:
     Uncaught Error: FAIL!!
      at fail (fixed-test.js:6:11)
      at Context.<anonymous> (fixed-test.js:10:5)
      at processImmediate (internal/timers.js:439:21)

--unhandled-rejections=strictでエラー終了させる

Node.js 12から--unhandled-rejections=modeというコマンドライン引数が追加されています。

--unhandled-rejections=strictでNode.jsを実行すると先ほどのUnhandled Rejectionが自動的にuncaught exceptionとして例外を投げるようになります。そのため、Unhandled Rejectionsがエラー終了となります。

$  node --unhandled-rejections=strict main.js
/unhandled-reject-example/main.js:9
    throw new Error("ERRRRRRR!"); // failed after 1sec
          ^

Error: ERRRRRRR!
    at main (/unhandled-reject-example/main.js:9:11)

$ echo $?
1

おわりに

基本的にはUnhandled Rejectionが発生しないようにPromise#catchなどをしてちゃんとエラーハンドリングしたコードを書くことが大切です。 それでも、意図しない例外などは発生するので、unhandledRejectionuncaughtExceptionでのエラー時の処理を入れておくのが良い気がします。

Async Functionを使って簡単にPromiseを扱えるようになって、このUnhandled Rejectionsの問題をよく見るようになった気がするのでこの記事を書きました。

サンプルリポジトリ:

Node.jsではEventEmitter.captureRejectionsなど新しいオプションが増えたりしているので、興味がある人はこの辺も見てみると良い気がします。

追記: Node.js側でのデフォルトの挙動を変更するPR