🙅

Promise.thenとasync/awaitは混ぜるな危険。まぎらわしくてcatch処理を書き忘れる

2022/09/24に公開約5,300字

こんなPromiseを書いたりしていませんか?

Node.jsでJSを書いていて、非同期通信にPromiseを使うことはよくありますよね。
非同期なのでエラーになることも考えtry-chatch文を使って安全性にも配慮が必要です。
例えばこんなコードはどうでしょうか?

コード例

try {
  await getPromiseResolve(); //←resolveするだけのPromise
  new Promise((resolve, reject)=>{
    reject("[test]:promise reject");
  })
  .then((result) => {
    console.error("promise -> then : result =", {result});
  });
} catch (error) {
  console.error("promise -> catch: error =", {error});
}

一見問題なさそうなコードがエラー発生・・・

上記のコードを実行します。

$node ●●.js

すると何とエラーになってします。
さらに悪いことに、Node.js 15以降を使用していて、PromiseにEventハンドラ登録が不完全な場合は、 Unhandled Rejection が発生しサーバーが落ちてしまいます。

ターミナルエラー内容

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 "[test]:promise reject".] {
  code: 'ERR_UNHANDLED_REJECTION'
}

エラーの理由

エラー理由ですが、new Promiseを生成した時にcatchの指定をしていないことが原因です。
Promiseのtry-catch自体は単体で見るとまあそうだよねという程度のものです。

しかし、まぎらわしいことに await はcatchをドットシンタックスでなくtry-catch文で書くことが多いので、awaitとPromiseのthen記述が混在した時に結構Promiseのcatchを書き忘れやすいです。

ななめ読みで見返して、『ああ、catch書いてあるし問題ないはず』と先入観でちゃんと書いていると錯覚してしまい、バグの原因になることがしばしばあります。

エラー箇所解説

new Promise((resolve, reject)=>{
  reject("[test]:promise reject");
})
.then((result) => {
  console.error("promise -> then : result =", {result});
})
//↓ここに「.catch()」が抜けている
;

なお、Promiseの内部のEventハンドラやフラグの詳細仕様解説は Zenn > PromiseのUnhandled Rejectionを完全に理解する の記事が詳しかったので合わせてご参照ください。

対処法

対策案1: Promiseに漏れなく.catch()を指定するパターン

対策案1としてはPromiseを生成したら忘れずに.catch()のEventハンドラを登録すること。
ただ、これだとcatch忘れを根性と集中力で対処するので、コードレビュー体制やLinter等での支援があることが望ましいです。

Promiseに.catch()追加

try {
  await getPromiseResolve(); //←resolveするだけのPromise
  new Promise((resolve, reject)=>{
    reject("[test]:promise reject");
  })
  .then((result) => {
    console.error("promise -> then : result =", {result});
  })
  //↓.catch()を追加
  .catch((error)=>{
    console.error("promise -> then -> catch : error =", {error});
  });
} catch (error) {
  console.error("promise -> catch: error =", {error});
}

対策案2: 全部awaitで呼び出すパターン

対策案2としては、Promiseの呼び出しは.then()や.catch()を使わず全てawaitで呼び出す方法です。
これはコードの記述ルール、レギュレーションを設定してプログラマー全員で書き方を統一していくことになります。

全てawait(promise.then未使用)

try {
  await getPromiseResolve(); //←resolveするだけのPromise
  const promise = new Promise((resolve, reject)=>{
    reject("[test]:promise reject");
  });
  const result = await promise; //← awaitで呼び出す
  
  console.log("await -> success: result=", result);
} catch (error) {
  console.error("await -> catch: error =", {error});
}

テストコード全文

今回は以下のコードで検証をしました。全文を記載しておきます。

テストコード全文

'use strict;'
const http = require("http");

http.createServer((req,res)=>{
  (async()=>{
    const getPromise = ()=>{
      return new Promise((resolve, reject)=>{
        resolve("test:promise resolve");
        // reject("test:promise reject");
      });
    }
/*
    // エラー発生するパターン
// ---
    try {
      await getPromise(); //←resolveするだけのPromise
      new Promise((resolve, reject)=>{
        reject("[test]:promise reject");
      })
      .then((result) => {
        console.error("promise -> then : result =", {result});
      });
    } catch (error) {
      console.error("promise -> catch: error =", {error});
    }
// ---
*/

/*
    // Promise.catch()を追加したパターン
// ---
    try {
      await getPromise(); //←resolveするだけのPromise
      new Promise((resolve, reject)=>{
        reject("[test]:promise reject");
      })
      .then((result) => {
        console.error("promise -> then : result =", {result});
      })
      .catch((error)=>{
        console.error("promise -> then -> catch : error =", {error});
      });
    } catch (error) {
      console.error("promise -> catch: error =", {error});
    }
// ---
*/

    // 全てawait(promise.then未使用)のパターン
// ---
    try {
      await getPromise(); //←resolveするだけのPromise
      const promise = new Promise((resolve, reject)=>{
        reject("[test]:promise reject");
      });
      const result = await promise;
      
      console.log("await -> success: result=", result);
    } catch (error) {
      console.error("await -> catch: error =", {error});
    }
// ---

    res.setHeader("Content-Type", "text/plain;charset=utf-8");
    res.write("【テスト】Promise.then() と Async/Await構文を混在させた場合のエラー検証");
    res.end();
  })();
}).listen(4000,()=>{
  console.log("Listening on localhost port 4000 (http://localhost:4000/)");
});

テストコード使い方
任意の「●●.js」ファイルを作成し、テストコード全文をコピー&ペーストしてください。
テストしたい3パターンに応じてコメントアウトを追加/削除してください。

  • エラー発生するパターン
  • Promise.catch()を追加したパターン
  • 全てawait(promise.then未使用)のパターン

ファイルを保存したらターミナル等でNode.jsを以下のように実行することで確認できます。

node ●●.js

まとめ

絶対にこうすれば防げるという方法はないですが、非同期通信のchatchで一歩間違えるとサーバクラッシュになりえるという知識があるだけでも慎重に対応する動機になると思います。
またユニットテストでPromiseのrejectケースを確認することも良い対応になりそうですね。


実行環境:

Mac monterey v12.6
node.js v16.17.1
chrome v105

参考にさせて頂いた記事:

https://maximorlov.com/why-you-shouldnt-mix-promise-then-with-async-await/

https://zenn.dev/uhyo/articles/unhandled-rejection-understanding

Discussion

ログインするとコメントできます