Node.jsの例えばfs.rename
のような非同期のメソッドを使っていたときなどに、
リトライしようとして、それをどうやって実現するかという話です。
コールバックだと単純にはうまくいきません。
いろいろ方法はあると思いますが、一例を説明しようと思います。
Node.jsのv12以降なら動くと思います。
サンプルの関数
気軽に実行するには
https://repl.it/
とかのNode.jsに貼り付けて実行してもらえればと思います。
例えば次のような関数があったとします。fs
モジュールとかfsExtra
モジュールの非同期系メソッドだと思ってください。
同期系はわけあって使いたくないです。
awaitを使うのと同期系のメソッドを単に使うのとでは次のように違いがあります。
asyncというキーワードをメソッドに宣言した上で、awaitというキーワードを使うことで手続き型らしくも非同期的な処理を記述することが出来ます。注意してほしい点としては、あくまでも開発者が同期的に書き下すことができるだけであって、つまりはコンパイラ(インタプリタ)がよしなに非同期に書き換えているということです。 非同期処理を理解する - Sansan Builders Blog
async function dummyMove(src, dst, option, cb) { console.log(`src: ${src}`); console.log(`dst: ${dst}`); console.log(option); return new Promise((resolve, reject) => { setTimeout(() => resolve(cb('error occurred')), 500) }); }
なにやら引数を受け取って、500ミリ秒経過後に非同期で必ずエラーを返す関数です。
今回の例ではPromise
を使っているのですが、async function
にしています。
本来はPromise
を返す場合はasync
にする必要がないのですが、特に無害なのでasync
にします。
呼び出し方はこんな感じです。
第一形態
(() => { dummyMove("aaa1.txt", "bbb1.txt", { overwrite: true }, (err) => { if (err) { console.log(`error occurred. src: aaa1.txt, dst: bbb1.txt, error: ${err}`); } else { console.log(`succeeded. src: aaa1.txt, dst: bbb1.txt`); } }); })();
さて、ここでこのdummyMove
を複数回呼び出す必要があります。
しかもわけあって、一つ目が終わったら二つ目というように処理をしないといけません。
第二形態
じゃあ、関数化するか…。で、こいつはコールバックを使ってるからPromise
もしくはasync
にしてやればいいねってなります。
async function moveWithRetry(src, dst) { return new Promise((resolve, reject) => { dummyMove(src, dst, { overwrite: true }, async (err) => { if (err) { console.log(`error occurred. src: ${src}, dst: ${dst}, error: ${err}`); reject(`failed: src: ${src}, dst: ${dst}, error: ${err}`); } else { console.log(`succeeded. src: ${src}, dst: ${dst}`); resolve(null); } }); }); } (async () => { await moveWithRetry("aaa1.txt", "bbb1.txt").catch(() => console.log('e1')); await moveWithRetry("aaa2.txt", "bbb2.txt").catch(() => console.log('e2')); })();
ちなみにですが、さっきのケースとは異なり、
今回のルールでは二つ目は一つ目が終わってからでないとダメというルールがあるのでawait
をつけてあげます。
つけないとどうなるかは外して何度か実行してみるとわかると思います。パラレルに実行される感じになります。
さらに、これだとエラーかどうかにかかわらず、必ず一つ目も二つ目も実行されます。
こう書いた場合、
try { await moveWithRetry("aaa1.txt", "bbb1.txt"); await moveWithRetry("aaa2.txt", "bbb2.txt"); } catch(e) { console.log('e'); }
この場合は当然一つ目で失敗すると二つ目は呼ばれません。 どちらが適切かはケースバイケースだと思います。
第三形態
さて、ここで、困ったことが起きました。
なんか知らないけれど、(httpとかの例にすればよかったけれど)たまーに失敗することがあるそうです。
ということでリトライ処理を入れましょう。
ああ、簡単。と思って…同期処理に慣れている人が書くと事故ります(僕です)。
ダメな例
async function moveWithRetry(src, dst, retryCnt = 0, maxRetry = 5) { return new Promise((resolve, reject) => { console.log(`retryCnt: ${retryCnt} / maxRetry: ${maxRetry}`); while (retryCnt < maxRetry) { dummyMove(src, dst, { overwrite: true }, async (err) => { if (err) { console.log(`error occurred. src: ${src}, dst: ${dst}, error: ${err}`); reject(`failed: src: ${src}, dst: ${dst}, error: ${err}`); retryCnt++; } else { console.log(`succeeded. src: ${src}, dst: ${dst}`); resolve(null); } }); } }); } (async () => { await moveWithRetry("aaa1.txt", "bbb1.txt").catch(() => console.log('e1')); await moveWithRetry("aaa2.txt", "bbb2.txt").catch(() => console.log('e2')); })();
この場合、どうなるかというと、ほぼ無限ループ状態になってしまいます。
というのも、コールバック内でカウントしても、コールバックが呼ばれる前にdummyMoveをものすごい速さで呼ぶので、意図した動きになりません。
ということで、エラーの時はretryCntを超えていなければ、再帰的にmoveWithRetry
を呼んであげればいい感じになります。
async function moveWithRetry(src, dst, retryCnt = 0, maxRetry = 5) { return new Promise((resolve, reject) => { if (retryCnt >= maxRetry) { console.log('retryCnt exceeded!'); reject(`failed: src: ${src}, dst: ${dst}`); } dummyMove(src, dst, { overwrite: true }, async (err) => { if (err) { console.log(`error occurred. src: ${src}, dst: ${dst}, error: ${err}`); console.log(`retryCnt: ${retryCnt}`); if (retryCnt < maxRetry) { console.log('retry!'); await moveWithRetry(src, dst, ++retryCnt).catch((e) => reject(e)); } else { reject(`failed: src: ${src}, dst: ${dst}, error: ${err}`); } } else { console.log(`succeeded. src: ${src}, dst: ${dst}`); resolve(null); } }); }); } (async () => { await moveWithRetry("aaa1.txt", "bbb1.txt").catch(() => console.log('e1')); await moveWithRetry("aaa2.txt", "bbb2.txt").catch(() => console.log('e2')); })();
こうすれば意図通りです。
まぁそんなわけでPromise
を返してくれない旧来のAPIはちょっとこういうことをしようと思うと、妙に苦労します…。
補足
なんか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().
とか出るんですけどって思って書いたときのコードがコレ。
しかも二つ目が実行されないし。
async function moveWithRetry(src, dst, retryCnt = 0, maxRetry = 5) { return new Promise((resolve, reject) => { if (retryCnt >= maxRetry) { console.log('retryCnt exceeded!'); reject(`failed: src: ${src}, dst: ${dst}`); } dummyMove(src, dst, { overwrite: true }, async (err) => { if (err) { console.log(`error occurred. src: ${src}, dst: ${dst}, error: ${err}`); console.log(`retryCnt: ${retryCnt}`); if (retryCnt < maxRetry) { console.log('retry!'); await moveWithRetry(src, dst, ++retryCnt); } else { reject(`failed: src: ${src}, dst: ${dst}, error: ${err}`); } } else { console.log(`succeeded. src: ${src}, dst: ${dst}`); resolve(null); } }); }); }
ひとつ前のコードと比べてもらうとわかりますが、await moveWithRetry(src, dst, ++retryCnt);
でcatch
していません。
なんかエラーがでたら落ち着いてコードを見直しましょう。
参考
https://qiita.com/G-awa/items/652107a9abf7ff6d0d06
https://qiita.com/hey1you1/items/a9b144c94f84cd1d91b8
https://ja.javascript.info/async-await
https://stackoverflow.com/questions/18581483/how-to-do-repeated-requests-until-one-succeeds-without-blocking-in-node
https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Async_await