目的 (ゴール)
ジェネレータ関数を使用したリトライ機能付きリクエスト送信処理の実装を行う。
対象者
- フロントエンド経験者
背景・コンテキスト
産業メンタルヘルスの問診画面の開発時、ユーザーステータスの更新リクエストに失敗しログアウトする事例が報告されており、リトライ機能の実装が必要になった。
実装方法について検討していた際、以前ジェネレータ関数を使用して成功するまで指定回数分リクエストを送信する処理を実装していたため、これを流用すれば工数が削減できると考えた。
ジェネレータ
function* 宣言で定義される関数によって返されるオブジェクト。
ジェネレーターは処理を抜け出したり、後から復帰したりすることができる関数です。ジェネレーターのコンテキスト(変数の値)は復帰しても保存されます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/function*
function* infinite() {
let index = 0;
while (true) {
yield index++;
}
}
const generator = infinite(); // "Generator { }"
console.log(generator.next().value); // 0
console.log(generator.next().value); // 1
console.log(generator.next().value); // 2
つまりジェネレータと非同期処理を組み合わせれば、リクエスト完了後に値を確認して失敗していれば再実行、という処理を実装できます。
for await of
非同期の反復可能プロトコルを実装している非同期ジェネレーターであれば、 for await...of を使用して繰り返し処理を行うことができます。
これらを組み合わせると以下のような実装になります。
async function* polling(request: () => Promise<any>, limit = 5) {
if (typeof request !== "function") {
throw new Error("callback must be a function.");
}
let pollingCounter = 0;
// 実行するとwhileの中に入る。
while (pollingCounter < limit) {
pollingCounter++;
console.log(pollingCounter)
yield await request();
}
yield null;
}
async function api(){
// なんか非同期の処理
}
const remind = async (count:number = 5): Promise<void> => {
// ここにジェネレータ関数
for await (let result of polling(api, count)) {
// 試行回数を超えるとnullが返るため、ここで処理を止める
if (result === null) {
break;
}
if (result) {
// ここでレスポンスを使った処理
break;
}
// 繰り返す場合はbreakしない。
}
// これでジェネレータを止める
polling.return()
}
やり方
今回は上記の実装を'@tanstack/react-query'と組み合わせて実装します。
コード
// カスタムエラーを定義
// 上限回数に達した場合のエラー
export class LimitExceededError extends Error {
constructor(message?: string) {
super(message);
this.name = 'LimitExceededError';
}
}
// ユーザー操作で中止された場合のエラー
export class AbortError extends Error {
constructor(message?: string) {
super(message);
this.name = 'AbortError';
}
}
// 本体の非同期ジェネレーター関数
async function* asyncGenerator<T>(asyncFn: () => Promise<T>, limit = 5) {
if (typeof asyncFn !== 'function') {
throw new Error('callback must be a function.');
}
// 非同期関数の実行回数をカウントする変数
let pollingCounter = 0;
// pollingCounterがlimitに達するまで非同期関数を実行
while (pollingCounter < limit) {
pollingCounter++;
yield await asyncFn();
}
// limitに達した場合はLimitExceededErrorを投げる
throw new LimitExceededError();
}
// loopAsyncFnは、非同期関数をリトライ可能にするための関数
// asyncFnは非同期関数で、limitはリトライの上限回数
// 戻り値は、非同期関数の結果
export const loopAsyncFn = async <T>(
asyncFn: () => Promise<T | undefined | void>,
limit: number = 5
): Promise<T> => {
// 非同期ジェネレーターを作成
const asyncPolling = asyncGenerator(asyncFn, limit);
try {
let result: T | undefined = undefined;
// eslint-disable-next-line prefer-const
for await (let res of asyncPolling) {
// 非同期関数が成功した場合はfor awaitのループを抜ける
if (res) {
result = res;
break;
}
}
// ループを抜けてここで値を返す。
return result as T;
} finally {
// 必ずジェネレーターを閉じる
asyncPolling.return();
}
};
function useDummyQueryWithRetry(limit = 3) {
// useMutation
const { mutateAsync, isPending } = useMutation({
mutationFn: () => {
// loopAsyncFnを使って、非同期関数をリトライ可能にする
return loopAsyncFn<'成功'>(async () => {
try {
// ダミーのリクエストを実行
// 成功すればそのまま値を返す
return await dummyRequest(Math.random() > 0.3);
} catch (e) {
// 失敗した場合は、リトライのためにエラーを投げる
// リトライ上限に達した場合は終了
if (e instanceof LimitExceededError) {
throw e;
}
// ユーザーに再試行の確認を求める
const confirmResult = confirm('再試行しますか?');
if (!confirmResult) {
// ユーザーが中止を選択した場合はAbortErrorを投げる
throw new AbortError();
}
}
}, limit);
},
});
return {
mutateAsync,
isPending,
};
}
const dummyRequest = async (isSuccess: boolean = false): Promise<'成功'> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!isSuccess) {
reject(new Error('失敗'));
} else {
resolve('成功');
}
}, 1000);
});
};
