一時的な失敗だけをやり直します。回数で打ち切る書き方、待ち時間を伸ばす理由、やり直してはいけない失敗の見分け方、最後の失敗を握りつぶさない形までを実行して確かめます。
答え
回数で打ち切る for を書きます。 最後の失敗は投げ直します。
example.js
async function retry(run, times) {
let lastError;
for (let attempt = 1; attempt <= times; attempt++) {
try {
return await run(attempt);
} catch (error) {
lastError = error;
console.log(attempt + '回目 失敗: ' + error.message);
}
}
throw lastError;
}
let calls = 0;
const flaky = async () => {
calls++;
if (calls < 3) throw new Error('一時的');
return '成功';
};
console.log(await retry(flaky, 5));
console.log('呼んだ回数:', calls);
出力書き換えて実行できます
1回目 失敗: 一時的
2回目 失敗: 一時的
成功
呼んだ回数: 3
example.js
async function retry(run, times) {
let lastError;
for (let attempt = 1; attempt <= times; attempt++) {
try {
return await run();
} catch (error) {
lastError = error;
}
}
throw lastError;
}
try {
await retry(async () => {
throw new Error('だめ');
}, 3);
} catch (error) {
console.log('最後まで失敗:', error.message);
}
出力書き換えて実行できます
最後まで失敗: だめ
成功した時点で止まります。 5回まで許していても、3回で終わっています。
最後の失敗を握りつぶさない
全部失敗したら、最後の失敗をそのまま投げます。
example.js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const order = [];
order.push('前');
await sleep(0);
order.push('後');
console.log(order);
出力書き換えて実行できます
[ '前', '後' ]
example.js
function waitMs(attempt) {
return 200 * 2 ** (attempt - 1);
}
console.log([1, 2, 3, 4].map(waitMs));
出力書き換えて実行できます
[ 200, 400, 800, 1600 ]
return null で終わらせないでください。 呼び出し側が
「失敗した」のか「結果が無かった」のかを区別できなくなります。
待ってからやり直す
すぐにやり直しても、相手の状況は変わっていません。待ち時間を置きます。
example.js
async function retry(run, { times, canRetry }) {
let lastError;
for (let attempt = 1; attempt <= times; attempt++) {
try {
return await run();
} catch (error) {
lastError = error;
if (!canRetry(error)) {
console.log('やり直さない:', error.message);
throw error;
}
console.log(attempt + '回目 やり直す');
}
}
throw lastError;
}
const canRetry = (error) => error.message !== '入力が不正';
try {
await retry(async () => {
throw new Error('入力が不正');
}, { times: 3, canRetry });
} catch (error) {
console.log('終了:', error.message);
}
出力書き換えて実行できます
やり直さない: 入力が不正
終了: 入力が不正
javascript
async function badRetry(run, times) {
for (let i = 0; i < times; i++) {
try {
return await run();
} catch {
// 何もしない
}
}
return null;
}
const out = await badRetry(async () => {
throw new Error('通信できない');
}, 3);
console.log(out);
console.log(out === null ? '失敗か、結果が無いのか分からない' : '成功');
null
失敗か、結果が無いのか分からない
sleep() は自分で書きます。標準にはありません。
new Promise を書くのは、こういう「Promise でない仕組みを包む」ときだけです。
→ Promise — then / catch / finally と async / await
待ち時間は回ごとに伸ばします。これを指数バックオフと呼びます。
example.js
function waitMs(attempt, base = 200, max = 5000) {
return Math.min(base * 2 ** (attempt - 1), max);
}
console.log([1, 2, 3, 4, 5, 6].map((n) => waitMs(n)));
出力書き換えて実行できます
[ 200, 400, 800, 1600, 3200, 5000 ]
example.js
function canRetry(status) {
return status === 429 || status >= 500;
}
console.log([429, 500, 503, 400, 401, 404].map((code) => [code, canRetry(code)]));
出力書き換えて実行できます
[ [ 429, true ], [ 500, true ], [ 503, true ], [ 400, false ], [ 401, false ], [ 404, false ] ]
なぜ伸ばすのか。 相手が混んでいるときに全員が同じ間隔で叩き続けると、
混雑がいつまでも解けません。下がってやり直すのが、結果として速くなります。
実務では、ここに少しのばらつき(ジッター)を足します。同時に失敗した全員が同じ時刻に再開すると、また同時に殺到するためです。
やり直してはいけない失敗がある
これがいちばん大事です。 何度やっても同じ結果になる失敗をやり直すのは、
無駄なだけでなく相手への攻撃になります。
| 失敗 |
やり直す? |
| 通信が切れた |
する |
429(多すぎる) |
する(待ってから) |
500 502 503 |
する |
400(送った内容が悪い) |
しない |
401 403(権限が無い) |
しない |
404(無い) |
しない |
判定を渡せるようにしておきます。
example.js
function shouldStop(startedAt, now, limitMs) {
return now - startedAt >= limitMs;
}
console.log(shouldStop(1000, 1500, 3000));
console.log(shouldStop(1000, 4500, 3000));
出力書き換えて実行できます
false
true
1回で止まりました。 直らない失敗を3回繰り返しても意味がありません。
よくある間違い
失敗を握りつぶして null を返す
呼び出し側で区別できません。 何が起きたのかも分かりません。
catch {} を空にするのは、問題を消しているだけです。
上限を決めずに待ち時間を伸ばす
2 ** attempt は、回を重ねると一気に大きくなります。頭を打たせてください。
上限が無いと、6回目で6400ミリ秒、10回目には100秒を超えます。
利用者が待てる時間には限りがあります。
状態コードで決める
判定を1か所の関数にまとめておくと、あとで変えやすくなります。
同時に走らせる数を抑える話は、こちらにまとめてあります。
→ 配列を決まった数ずつに分ける
同じ操作を2回してよいか
やり直すということは、同じ操作が2回実行されうるということです。
- 読み出し(取得)… 何回やっても同じ。安全
- 作成(登録・送金)… 2件できるかもしれない
「何回実行しても結果が同じ」ことを冪等と言います。
冪等でない操作をやり直すときは、同じ操作だと相手に伝える印
(同じ識別子を毎回送るなど)が要ります。
再試行を足す前に、その操作が2回起きても大丈夫かを確かめてください。
打ち切りも決める
回数だけでなく、全体の時間でも止められるようにしておくと安全です。
実際の時刻は実行のたびに変わるので、ここでは値を渡して判定だけを見せています。
通信そのものにも上限を付けてください。fetch() なら AbortSignal.timeout() が使えます。
やり直す前に、1回ずつを必ず終わらせるのが前提です。
まとめ
- 回数で打ち切る
for。成功したらその場で返す
- 最後の失敗は投げ直す。
null で握りつぶさない
- 待ち時間は回ごとに伸ばす(指数バックオフ)
- 直らない失敗はやり直さない。
400 401 403 404 は1回で止める
- 2回実行されても大丈夫かを先に確かめる
- 回数だけでなく全体の時間でも打ち切る
再試行は、失敗が一時的なものだけに効きます。
そもそも何が失敗なのかを見分けるところから始めてください。
→ SyntaxError: Unexpected end of JSON input