1. Qiita
  2. 投稿
  3. JavaScript

Promiseとasync-awaitの例外処理を完全に理解しよう

  • 241
    いいね
  • 2
    コメント

はじめに

JavaScriptは非同期処理との闘いです。

人類が非同期処理を倒すために、Promiseasync-awaitという最終兵器を生み出して、劇的にクリーンで平和な世界が生まれたという話は以前しました :innocent:
=> (もしかして: JavaScriptは如何にしてAsync/Awaitを獲得したのか Qiita版)

しかあぁし!!! 甘い、甘いのですよ!!!!! :trollface: :punch:

人類を苦しめ続ける非同期処理が、そんな簡単に完全に倒せるわけがないのですよ。

非同期処理の本当にヤバイ深淵、それが「例外処理」です。

みなさんはPromiseで開発していて、

なんか途中までうまく行ってたんだけど気づいたら例外が外側に飛ばなくなった…なんでだ…
助けて!Promiseにcatch書いてるのに何故か例外がcatch出来ないの!!!

という経験はないでしょうか。私は何度もあります。

この記事では、具体的に何がどうなったら例外がもみ消されるのか、どうやったら例外が飛ぶようになるのか、色んなパターンを紹介をしていけたら良いなと思います。

非同期のおさらい

早速ですが問題です。以下を実行すると例外はどう飛ぶでしょうか。

問題1
setTimeout(()=> {
    throw new Error("ガッ");
}, 0);

throw new Error("ぬるぽ");

正解は、処理系依存です。

ブラウザで実行すると、「ぬるぽ」「ガッ」の順番でそれぞれ例外が飛ぶと思います。
Node.jsだと「ぬるぽ」の例外が発生した時点でプロセスが終了します。そのため「ガッ」が出ません。

従って両者で結果を共通にするには、以下のように書く必要があります。

問題1の改良
try {
    setTimeout(()=> {
        try{
            throw new Error("ガッ");
        } catch(err) {
            console.error((err && err.stack) || err);
        }
    }, 0);

    throw new Error("ぬるぽ");
} catch(err) {
    console.error((err && err.stack) || err);
}

厳密に言えばこれでも完全には共通ではないんですが、簡易的にはこんな感じです。

try-catchでは同期処理例外しかキャッチできません。

なのでブラウザは(スクリプト実行プロセスが落ちたら困るので)、エントリポイントの同期関数と、それ以外のすべての非同期関数にtry-catchがついてるような状態なのです。
(※くどいようですがあくまでイメージです)

この話がのちのち重要になってくるので、頭の片隅に置いといてください。

Promiseと例外処理

ここからがメインの話です。

①Promise内部の同期実行で例外が発生するケース

Promiseの同期エラー
new Promise((resolve, reject)=> {
    throw new Error("ぬるぽ");
});

一番基本的な部分です。
Promise内部で同期エラーが発生した場合には、それをrejectとして処理をします。

従って、あえて冗長な書き方をすると、以下のような意味と同じになります。

Promiseの同期エラー(冗長表現)
new Promise((resolve, reject)=> {
    try{
        throw new Error("ぬるぽ");
    } catch(err){
        reject(err);
    }
});

このため、Promise内で発生した例外は、thenの第二引数、もしくはcatchで取得することが出来ます。

Promiseの同期エラーをcatch
new Promise((resolve, reject)=> {
    throw new Error("ぬるぽ");
}).catch((err)=> {
    console.error((err && err.stack) || err);
});

逆を言えば、catchし忘れると、例外通知がされずにもみ消される可能性があります。

静かにプログラムを終了する
new Promise((resolve, reject)=> {
    throw new Error("ぬるぽ");

    console.log("因みにここには到達しないので表示できない");
});

この辺りはUnhandled rejection Errorなんかをブラウザ独自で実装して、例外を取れるようにしている場合もあるんですが、非標準のはずです。(だよね?)

(16/06/06) ES2016からはUnhandled Rejectionとして例外をキャッチできるようになるようですが、ES2015ではキャッチできないようです。(詳しくはコメント欄で)

なので現行のNode.jsではこれに対して特にハンドリングをしていないので、何も表示せずにしれっと終了するはずです。

②Promise内部で非同期例外が発生するケース

これが厄介です。

Promise内部で非同期例外が発生する例
new Promise((resolve, reject)=> {
    setTimeout(()=>{
        throw new Error("非同期ぬるぽ");
    }, 0);
}).catch((err)=> {
    console.error("エラーでたっぽい");
});

恐らくこれを見て「『エラーでたっぽい』と表示して欲しい」と誰もが願うでしょう。

しかし残念ながらこれはcatch出来ません。 なんと「非同期ぬるぽ」例外を出します。

これも①の時と同様に、冗長表現で再度書き下すと以下のようになりますよね。

Promise内部で非同期例外が発生する例の冗長表現
new Promise((resolve, reject)=> {
    try{
        setTimeout(()=>{
            throw new Error("非同期ぬるぽ");
        }, 0);
    } catch(err) {
        reject(err);
    }
}).catch((err)=> {
    console.error("エラーでたっぽい");
});

そして非同期処理のおさらいの時に言った言葉を思い出してください。

――try-catchは同期例外しかキャッチできない。

賢い皆さんならもうおわかりいただけたかと思います。

この記述ではsetTimeout内の例外が非同期例外であるため、
Promise側に暗黙的に用意されているtry-catchでキャッチすることが出来ず、
結果として非同期例外が外側にスローされてしまいます。

そのため、Promise内部で非同期処理を記述する場合は、try-catchで囲っておくのが無難です。

Promise内部で非同期例外をrejectする例
new Promise((resolve, reject)=> {
    setTimeout(()=>{
        try{
            throw new Error("非同期ぬるぽ");
        } catch(err) {
            reject(err);
        }
    }, 0);
}).catch((err)=> {
    console.error("エラーでたっぽい");
});

これで、当初望んでいた挙動になりますね :innocent:

当然、「絶対に例外を非同期内部で出さないぞ!!」という強い意志がある場合のみtry-catchが省略可能です。

絶対に例外を非同期内部で出さない自信があるコードの例
function sleepFetch(ms){
    return new Promise(resolve=> setTimeout(resolve, ms));
}

この辺りは本当にバグの元なので、常に例外がどうなるかを意識しながら設計・記述するように心がけてください。

③catchを複数記述した時のPromise

これはおまけの内容ですが、理解してないと次のasync-awaitの話で爆死するのでおさらいです。

catchを複数記述した時のPromiseの例
Promise.reject("結論だけ、書く。")
    .catch(()=> { console.log("失敗した"); })
    .catch(()=> { console.log("失敗した"); })
    .catch(()=> { console.log("失敗した"); });

Q.このような記述があった場合、結果はどのようになるでしょうか(3択問題)

  1. 「失敗した」が3回表示される
  2. 「失敗した」が1回表示される
  3. ショックのあまり自殺する

正解は3番2番です。1回しか表示されません。

というのも、1回目の「失敗した」を表示するcatchの関数が、Promiseを返さずに、undefinedを返します。
そして、thenやcatchがPromise以外を返している場合は、暗黙的にPromise.resolveとみなされるのです。
(※因みにthenやcatch内で同期例外が起きた場合は、暗黙的にPromise.reject(err)とみなされます。)

以上のことから、先ほどのコードを冗長に記述すると以下のようになりますよね。

catchを複数記述した時のPromiseの例(冗長な記述)
const undefined = (0, eval)("this").undefined;

Promise.reject("結論だけ、書く。")
    .catch(()=> {
        console.log("失敗した");
        return Promise.resolve(undefined);
    })
    .catch(()=> {
        console.log("失敗した");
        return Promise.resolve(undefined);
    })
    .catch(()=> {
        console.log("失敗した");
        return Promise.resolve(undefined);
    });

こうやって見てみると一目瞭然です。
1回目のcatchがrejectではなく、resolveを返しているので、2回目以降のcatchは実行されないのです。

この性質はthenでも同様です。これを読んだPromiseマスターのあなたなら以下のコードがどう表示するかわかりますよね?

thenとcatchが入り組んだ複雑なPromiseの例
Promise.reject()
    .then(()=> { console.log("成功したA"); }, ()=> { console.log("失敗したA"); })
    .then(()=> { console.log("成功したB"); }, ()=> { console.log("失敗したB"); })
    .then(()=> {
        console.log("成功したC");
        return Promise.reject();
    })
    .catch(()=> {
        console.log("失敗したC");
    })
    .catch(()=> {
        console.log("失敗したD");
    })
    .then(()=> {
        console.log("成功したD");
        return Promise.reject();
    })
    .catch(()=> {
        console.log("失敗したE");
        throw new Error("ぬるぽ");
    })
    .catch(()=> {
        console.log("失敗したF");
    })
    .then(()=> {
        console.log("成功したE");
        throw new Error("ぬるぽ");
    })
    .catch(()=> {
        console.log("失敗したG");
    });

これの結果は

「失敗したA」⇒「成功したB」⇒「成功したC」⇒「失敗したC」⇒「成功したD」⇒「失敗したE」⇒「失敗したF」⇒「成功したE」⇒「失敗したG」

です。合っていたか確認してみてください。
(※特に、「成功したD」の後に「失敗したD」が来ると勘違いした人は要復習です)

async-awaitと例外処理

実はPromiseの例外処理を完全に理解したらこちらも理屈はほぼ一緒です。

現段階でasync-awaitはstage-3ではありますが、例外周りの議論はほぼ終わっているので、このままの仕様でstage-4になる…はず。

④async関数内部の同期実行で例外が発生するケース

これは完全にPromiseと同じです。例外を投げたらreject扱いになります。

async関数内部で同期例外を投げる例
(async ()=>{
    throw new Error("ぬるぽ");
})().catch(err=> {
    console.error("エラーでたっぽい");
})

catch出来ます。 Promiseと同じ挙動なので説明省略。

⑤async関数内部のawaitしない非同期実行で例外が発生するケース

これもPromiseと基本的に同じです。非同期例外はrejectされません。
しかもPromise単体の時よりも闇が深くなっています。

async関数内部でawaitしない非同期例外が発生する例
(async ()=>{
    setTimeout(()=>{
        throw new Error("ぬるぽ");
    }, 0);
})().catch(err=> {
    console.error("エラーでたっぽい"); // ←呼ばれないっぽい!!!
});

解決方法もPromiseと同じです。try-catchで囲め、が原則。

async関数内部でawaitしない非同期例外が発生する場合の解決例
(async ()=>{
    setTimeout(()=>{
        try{
            throw new Error("ぬるぽ");
        } catch(err) {
            console.error("非同期エラーが出たからこの文脈で捌くよ");
        }
    }, 0);
})().catch(err=> {
    console.error("エラーでたっぽい"); // ←呼ばれないっぽい!!!
});

ただし、見ての通りPromiseの時と違ってasync関数にはresolveがないので、外側のcatchにはどうあがいても到達出来ません。
各々の非同期処理で責任持って完結してください。
(※このように面倒事が多いので、async関数の中でawaitしない非同期処理を書くのは個人的にはアンチパターンです。やむを得ない場合を除いて、避けたい書き方であります。)

⑥async関数内部のawaitした同期実行で例外が発生するケース

この場合は、Promise.rejectにラップされるため、catch出来ます。

async関数内部でawaitする同期例外が発生する例
(async ()=>{
    await (()=>{ throw new Error("ぬるぽ"); })();
})().catch(err=> {
    console.error("エラーでたっぽい"); // ←呼ばれる
});

⑦async関数内部のawaitした非同期実行で例外が発生するケース

この場合は、非同期実行がPromise.rejectを返すため、catch出来ます。

async関数内部でawaitする非同期例外が発生する例
(async ()=>{
    await new Promise((resolve, reject)=>{ throw new Error("ぬるぽ"); });
})().catch(err=> {
    console.error("エラーでたっぽい"); // ←呼ばれる
});

(※Promiseの中に非同期関数があってその中で例外があった場合は、②案件なのでtry-catchで囲わなかったら死にます。)

⑥,⑦の挙動からみてわかるように、async-awaitはちゃんとawaitさえしとけばcatch出来ます。

awaitしないasync-awaitは存在意義が無いので、

async関数を利用する場合は、非同期処理を必ずawaitしてください。

↑この記事で一番言いたかったこと :eyes:

⑧async関数内部のawaitした非同期実行をcatchしてしまったケース

:warning: 先ほどの話を聞いて「思考停止awaitしよう」と考えた人にとって、一番注意すべきがこれです。

async関数内部のawaitした非同期実行をcatchしてしまった例
(async ()=>{
    // ↓これはいいんだよこれは
    await sleepFetch(1000);

    // ↓これをすると、外側のcatchで呼ばれなくなる
    await sleepFetchWithError(1000).catch(err=> {
        console.error("エラーでたっぽいから内部で処理した"); // ←呼ばれる
    });
})().catch(err=> {
    console.error("エラーでたっぽい"); // ←呼ばれない
});

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

function sleepFetchWithError(ms){
    return new Promise(resolve=> {
        throw new Error("非同期例外が出てしまった!!!");
    });
}

これの原因は、実は③で書いた内容で説明できます。
というのも、awaitにつなげたcatchが、Promise.resolve(undefined);を暗黙的に返しているので、resolve扱いにされてしまったのです。

③の時に散々復習したので、解決方法は覚えてると思います。throwするか、reject返すか、でしたね。

async関数内部のawaitした非同期実行をcatchした場合に外側に伝搬する例
(async ()=>{
    await sleepFetchWithError(1000).catch(err=> {
        console.error("エラーでたっぽいから内部で処理した"); // ←呼ばれる

        throw err; // もしくは、return Promise.reject(err);
    });
})().catch(err=> {
    console.error("エラーでたっぽい"); // ←こっちも呼ばれる
});

この仕様を逆手に取って、あえてawaitでrejectしないcatchで繋げて、外側のasync関数にrejectを伝搬させずに止める、というのも上級テクニックとしてあります。上手に使い分けてくださいね。

まとめ

ややこしい話、お疲れ様でした。
これだけ把握していたら、どんなパターンのPromiseやasync-awaitが来ても、正しく例外を操ることが出来ると思います。

今まで色んなPromiseやasync-awaitの記事を読んできましたが、非同期処理の例外はクソめんどくさい割には、まとまった記事がないなーと思って今回記事にしました。

JavaScriptと非同期処理は切っても切れない関係です。皆さんの理解が深まれば幸いです。

Comments Loading...