Promiseで処理が失敗したらリトライしたい
Promiseの結果次第でリトライをかけたい場合があります。簡単な実装は以下で、これはググるとすぐに出てきます。
function retry(func, retryCount) {
let promise = func();
for (let i = 1; i <= retryCount; ++i) {
promise = promise.catch(func);
}
return promise;
}
// 失敗時に5回までリトライ
retry(() => fetch("url"), 5).then(...)
// これは以下と同じ
fetch("url")
.catch(() => fetch("url"))
.catch(() => fetch("url"))
.catch(() => fetch("url"))
.catch(() => fetch("url"))
.catch(() => fetch("url"))
.then(...)
これは成功時は「実行してthenを処理」となるだけですが、失敗時は「catchで再実行を5回まで繰り返してthenを処理」します。
他にも凝った実装はStackover flowにまとまっているので、気に入ったものを選ぶとよいと思います。
これで十分?
リトライといってもただやり直すだけではなくて色々あると思います。今回は以下を考慮します。
- リトライまでの時間をコントロールしたい(Retry-Afterヘッダを尊重したいときとか、Exponential Backoffを入れたいときとか)
- 条件次第ではリトライしたくない(ステータスコード的にリトライが無意味な場合など)
- 途中でキャンセルしたい
サンプルコード
jsfiddleに動くコードを書いたので、動きを見てみたい方はご覧ください。
以下はjsfiddleが消えた時用
/**
* 指定されたミリ秒だけPromiseの完了を待つ。
* AbortSignalが渡されていたら、Signalの状態によってはキャンセル(reject)する。
*/
function sleepAsync(milliseconds, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, milliseconds);
if (signal) {
const onabort = () => {
clearTimeout(timer);
reject();
};
if(signal.aborted){
// イベント後に呼ばれると下のコールバックでは拾えないので、即座に呼び出してそのケースを考慮している
onabort();
}else{
signal.addEventListener('abort', onabort);
}
}
});
}
/**
* リトライ実装のベース
*/
function retry(func, retryDelay, determinationRetry, signal) {
return new Promise((resolve, reject) => {
const inner = (count) => {
func().then(resolve, (...rejectArgs) => {
if (determinationRetry(count, rejectArgs)) {
sleepAsync(
retryDelay(count, rejectArgs),
signal
).then(
() => inner(count + 1),
() => reject(...rejectArgs) // キャンセル時は最後の失敗時の結果を返す
);
} else {
reject(...rejectArgs);
}
});
};
inner(1);
})
}
これだと普段使いにはちょっと扱いにくいので、関数で包んだものを作ります。
条件次第でリトライしたくない場合などは第3引数の関数の内容を書き換えればよいでしょう。
// リトライ実装その1
const retryN = (maxcount, func, signal) => retry(
func,
() => 1000, // 1秒待つ
(count) => count <= maxcount, // リトライはmaxcount回まで
signal
);
// リトライ実装その2
// 本格派リトライ(最大10秒+α待ち)
const retryExponentialBackoff = (maxcount, func, signal) => retry(
func,
(count) => Math.min(2 ** count * 100, 10 * 1000) + Math.random() * 1000,
(count) => count <= maxcount,
signal
);
こんな感じに使います。
const canncelButton = document.getElementById('cancel');
// キャンセル用のオブジェクト(AbortControllerで代用)
const abortController = new AbortController();
abortController.signal.addEventListener('abort', () => {
alert("キャンセル操作が行われました");
});
// ボタンを押してキャンセルしたとき
const cancelEvent = (e) => {
abortController.abort();
};
canncelButton.addEventListener('click', cancelEvent);
// doTaskAsync(successRate).then((...successedArgs) => { ...
// とやっていた箇所を以下のように書き換える
retryExponentialBackoff(
5,
() => doTaskAsync(successRate),
abortController.signal
).then((...successedArgs) => {
console.log(successedArgs)
}, (...failedArgs) => {
console.log(failedArgs)
}).finally(() => {
canncelButton.removeEventListener('click', cancelEvent);
});
付録
AbortController
AbortControllerはfetch APIのキャンセルに使ったりするものです。(fetch APIが出来てしばらくはキャンセルが出来なかった。XMLHttpRequestは出来ていた)
まだ試験的ですがモダンブラウザには実装されていますし、なんちゃって実装ならEventTargetを使って簡単にかけます。
window.AbortController = function(){
const signal = new EventTarget();
signal.aborted = false;
return {
signal,
abort(){
signal.aborted = true;
signal.dispatchEvent(new Event('abort'));
}
}
};
最初は雑に実装して色々試していたんですが、sleep(setTimeout)中のキャンセルがスムーズにいかないのが気になって、簡単に実装できないか探したところたどり着いたのがAbortControllerでした。C#のCancellationTokenSourceに近いですね。
蛇足: fetchはHTTPステータスコードが5xx/4xxでもResolvedになる
上記の実装ではRejectedなときだけリトライするため、そのままfetchを使ったらリトライ処理になりません。
なので、例えば以下のようにしてあげる必要があります。
fetch("url").then((res) => res.ok ? res : Promise.reject(res))