TypeScriptでリトライ回数が上限に達した場合の型安全なエラーハンドリング方法

TypeScriptにおいて、ネットワーク通信やAPI呼び出しなど、外部リソースとのやり取りでは、失敗が発生する可能性があります。このため、エラーが発生した場合にリトライ処理を行うことはよく見られるパターンです。しかし、リトライ回数が上限に達した際に、適切なエラーハンドリングを行わないと、予期しない動作やバグを引き起こす可能性があります。本記事では、TypeScriptで型安全にリトライ処理とエラーハンドリングを実装する方法を解説します。

目次

リトライ処理の基本概念

リトライ処理とは、特定の操作が失敗した場合に再度その操作を試みる仕組みのことです。外部APIの呼び出しやネットワーク通信において、一時的な障害や接続問題が原因で処理が失敗することがあり、このような場合にリトライすることで、再試行の結果成功する可能性があります。
リトライ処理は、安定したシステムを実現するための重要な技術ですが、回数制限や適切なエラーハンドリングがないと無限ループやシステム負荷の増加を招く可能性があるため、慎重な設計が必要です。

リトライ回数が上限に達するケースの例

リトライ処理を実装する際、設定した回数を超えても操作が成功しないケースが発生することがあります。例えば、ネットワークエラーやサーバーの過負荷状態など、外部の問題によってリトライが繰り返されても状況が改善されないことがあります。

具体的なケースとコード例

以下は、APIリクエストを行う関数で、最大3回までリトライする処理の例です。

async function fetchDataWithRetry(url: string, retries: number = 3): Promise<any> {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error('Request failed');
            }
            return await response.json();
        } catch (error) {
            if (i === retries - 1) {
                throw new Error(`Max retries reached: ${error.message}`);
            }
        }
    }
}

この例では、fetchを利用してAPIにリクエストを送りますが、リクエストが失敗するたびにリトライを行います。リトライ回数が上限に達すると、最終的にエラーメッセージを出力し、処理を中断します。このように、リトライ回数を制限することが重要です。

型安全なエラーハンドリングの重要性

TypeScriptの強力な型システムを活用することで、エラーハンドリングも型安全に実装できます。型安全なエラーハンドリングを行うことで、コードの信頼性が向上し、予期しないエラーやバグを事前に防ぐことができます。特に、リトライ処理ではエラーの種類や結果の型を正確に扱うことが重要です。

型安全でないエラーハンドリングのリスク

例えば、エラーメッセージを文字列として処理するだけでは、エラーの詳細情報が失われる可能性があります。また、エラーの種類に応じた処理を実行したい場合、エラーハンドリングが型安全でないと、適切な分岐処理ができなくなり、デバッグが困難になることがあります。

型安全なエラーハンドリングのメリット

TypeScriptを用いて型を明確に定義することで、以下のようなメリットがあります:

  • 予測可能な挙動: エラーの種類や構造が型で定義されるため、どのようなエラーが発生しうるかが明確になります。
  • コードの信頼性: 実行時エラーを減らし、リトライ処理におけるバグを事前に検知できます。
  • 保守性の向上: 型があることで、エラーハンドリングの変更や追加があっても、その影響を型チェックによって把握できます。

このように、型安全なエラーハンドリングは、TypeScriptの強力な型システムと密接に結びついており、信頼性の高いコードを書くための鍵となります。

TypeScriptにおけるエラーハンドリングの基本手法

TypeScriptでは、JavaScriptと同様にtry-catch構文を用いてエラーを捕捉し、処理を制御することができます。ただし、TypeScriptの型システムを活用することで、エラーハンドリングにおいても型安全な実装が可能です。

`try-catch`構文の基本

以下は、try-catch構文を使用したシンプルなエラーハンドリングの例です。

function divide(a: number, b: number): number {
    if (b === 0) {
        throw new Error('Division by zero is not allowed');
    }
    return a / b;
}

try {
    const result = divide(10, 0);
    console.log(result);
} catch (error) {
    console.error('An error occurred:', error.message);
}

この例では、divide関数でゼロによる除算が試みられた場合にエラーをスローし、catchブロックでエラーを処理します。

型を考慮したエラーハンドリング

TypeScriptの特徴である型システムを活用することで、エラーオブジェクトにも型を付与して、安全に処理を行うことが可能です。例えば、エラーハンドリングにおいて、エラーが予想される種類ごとに型を定義しておくと、どのエラーが発生しているのかをより明確に処理できます。

class NetworkError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'NetworkError';
    }
}

class ValidationError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'ValidationError';
    }
}

function fetchData(): never {
    throw new NetworkError('Failed to fetch data');
}

try {
    fetchData();
} catch (error) {
    if (error instanceof NetworkError) {
        console.error('Network issue:', error.message);
    } else if (error instanceof ValidationError) {
        console.error('Validation issue:', error.message);
    } else {
        console.error('Unknown error:', (error as Error).message);
    }
}

このように、instanceofを使用してエラーの種類に応じた処理を行うことができます。また、エラーの型を事前に定義しておくことで、型安全かつ予測可能なエラーハンドリングが実現できます。

型アノテーションを活用したエラー処理

エラーハンドリングには、エラーオブジェクトに対して型アノテーションを付けることで、IDEによる支援や型チェックを活用しながらコードを記述できます。例えば、以下のようにcatchブロックでエラーを型キャストすることができます。

try {
    // エラーをスローする可能性のある処理
} catch (error) {
    const typedError = error as Error;
    console.error(typedError.message);
}

このような型アノテーションを用いることで、TypeScriptならではの型安全なエラーハンドリングを簡潔に実装することができます。

リトライと型の活用

TypeScriptの型システムを活用することで、リトライ処理におけるエラー管理をより安全に、かつ明確に実装できます。特に、リトライ処理で失敗した場合やリトライが成功した場合の結果を型で区別することで、コードの可読性や信頼性が向上します。

リトライ時の結果を型で定義する

リトライ処理の結果を明確にするために、リトライが成功した場合と失敗した場合を区別できる型を定義します。これにより、リトライの成否に応じた処理を型安全に行うことが可能になります。

以下のコードでは、リトライの成功・失敗を区別するResult型を定義しています。

type Success<T> = {
    success: true;
    data: T;
};

type Failure = {
    success: false;
    error: Error;
};

type Result<T> = Success<T> | Failure;

async function retryableFetch(url: string, retries: number = 3): Promise<Result<any>> {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error('Request failed');
            }
            const data = await response.json();
            return { success: true, data };
        } catch (error) {
            if (i === retries - 1) {
                return { success: false, error: error as Error };
            }
        }
    }
    return { success: false, error: new Error('Unknown error') };
}

この例では、retryableFetch関数が成功した場合はSuccess型、失敗した場合はFailure型を返します。リトライ処理が成功すればデータが返され、失敗すればエラーメッセージが含まれるため、後続の処理で明確に成功か失敗かを扱うことができます。

リトライ結果の処理

このResult型を使えば、リトライ処理の結果に応じて安全に後続の処理を行うことができます。例えば、以下のように結果を型に基づいて処理します。

async function handleRetry() {
    const result = await retryableFetch('https://api.example.com/data');

    if (result.success) {
        console.log('Data received:', result.data);
    } else {
        console.error('Fetch failed:', result.error.message);
    }
}

このように、リトライ処理の成否が型として明確に定義されることで、型安全に結果を扱うことが可能になります。エラーが発生しても型で処理を保証できるため、予期しない動作を回避できる点が大きなメリットです。

型を活用したエラーハンドリングのメリット

  • コードの安全性: 成功・失敗を型で明示的に扱うため、実行時エラーを未然に防ぐことができます。
  • コードの可読性向上: リトライの結果を型で明示することで、コードを読むだけで処理の流れが理解しやすくなります。
  • メンテナンス性の向上: 型に基づいたエラーハンドリングにより、後からコードを変更した場合でもバグが発生しにくくなります。

リトライ処理における型の活用は、TypeScriptの特徴を最大限に活かした安全なプログラム設計を可能にします。

エラーハンドリングのベストプラクティス

リトライ処理において、エラーハンドリングの実装は非常に重要です。適切なエラーハンドリングを行うことで、システムの信頼性やユーザー体験を向上させることができます。ここでは、TypeScriptを使ったリトライ処理におけるエラーハンドリングのベストプラクティスについて解説します。

1. エラーを明確に分類する

リトライ処理におけるエラーは、通常「再試行可能なエラー」と「再試行しても解決しないエラー」に分類されます。ネットワーク接続の一時的な失敗は再試行可能ですが、無効なAPIキーや認証エラーは再試行しても成功する可能性が低いため、即座に失敗として扱うべきです。

class RetryableError extends Error {}
class FatalError extends Error {}

このように、エラーの種類を定義して、それぞれのエラーに応じた処理を行うことがベストプラクティスです。

2. エラーログを適切に記録する

エラーハンドリングの一環として、失敗したリトライ回数やエラーメッセージをログに記録することが重要です。これにより、障害発生時に問題を追跡しやすくなります。また、ログによりユーザーへの影響を最小限に抑えることができます。

function logError(error: Error, attempt: number) {
    console.error(`Attempt ${attempt} failed: ${error.message}`);
}

各リトライが失敗した際に、ログを残すことで、エラーの詳細な情報を把握できます。

3. フォールバック処理を実装する

リトライ回数が上限に達した場合、システムの停止やクラッシュを防ぐためにフォールバック処理を実装します。例えば、別のサービスにフェイルオーバーしたり、デフォルトの応答を返したりすることが考えられます。

async function fetchDataWithFallback(url: string, fallbackData: any): Promise<any> {
    try {
        return await fetchDataWithRetry(url);
    } catch (error) {
        console.warn('Using fallback data due to error:', error);
        return fallbackData;
    }
}

このように、リトライがすべて失敗した場合でも、ユーザーに対して何らかのデータを返すことで、システムの柔軟性を高めることができます。

4. ユーザー通知とUIへの反映

リトライ回数が上限に達した際は、ユーザーに対して適切に通知することが重要です。特にUIベースのアプリケーションでは、エラーが発生した場合にユーザーが次に取るべきアクションを明示することが、ユーザー体験の向上に繋がります。

function notifyUser(errorMessage: string) {
    alert(`An error occurred: ${errorMessage}`);
}

UIへのエラー反映やユーザー通知を行うことで、ユーザーがシステムに対して不安や混乱を感じるのを防ぎます。

5. エラーの再スローを適切に行う

リトライが上限に達した際には、最終的なエラーを適切にスローして、上位の処理に伝える必要があります。これにより、全体のエラーハンドリングフローに組み込むことができ、システム全体で一貫したエラーハンドリングが実現できます。

async function fetchDataWithRetryAndThrow(url: string): Promise<any> {
    const result = await retryableFetch(url);
    if (!result.success) {
        throw result.error;
    }
    return result.data;
}

最終的にリトライが失敗した場合は、エラーを再スローして、上層の処理にエラーハンドリングを委ねます。

まとめ

TypeScriptにおけるリトライ処理では、エラーハンドリングを慎重に設計することが、システムの安定性とユーザー体験の向上に繋がります。エラーの分類、ログの記録、フォールバック処理、ユーザー通知、そしてエラーの再スローといったベストプラクティスを導入することで、堅牢で安全なエラーハンドリングが可能になります。

リトライ回数の制限と実装例

リトライ処理を実装する際、無制限にリトライを繰り返すことは、システムに負荷をかけるだけでなく、ユーザー体験を損なう原因にもなります。そのため、リトライ回数に制限を設けることが重要です。ここでは、リトライ回数の制限をどのように設けるか、その具体的な実装例を紹介します。

リトライ回数を設定する理由

リトライ処理が有効なのは、短期間の接続障害や一時的なサーバーの負荷など、解決可能なエラーに対してです。しかし、致命的なエラーや再試行しても解決しない問題に対して無制限にリトライを続けることは、無駄なリソース消費を引き起こします。そのため、リトライ回数の上限を設けて、適切なタイミングで処理を中断し、エラーをユーザーやシステムに伝える仕組みが必要です。

リトライ回数の制限を実装する

以下は、TypeScriptでリトライ回数の制限を設けた実装例です。この例では、retryableFetch関数が最大3回のリトライを行い、それでも失敗した場合はエラーをスローします。

async function retryableFetch(url: string, retries: number = 3, delay: number = 1000): Promise<any> {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error('Failed to fetch data');
            }
            return await response.json();
        } catch (error) {
            if (i < retries - 1) {
                console.warn(`Retry ${i + 1} failed. Retrying in ${delay}ms...`);
                await new Promise(resolve => setTimeout(resolve, delay));
            } else {
                console.error('Max retry limit reached.');
                throw error;
            }
        }
    }
}

コード解説

  1. retriesdelay: retriesはリトライ回数の上限を、delayはリトライの間に待機する時間(ミリ秒単位)を指定します。これにより、サーバーが回復する時間を与えたり、過度な負荷を防ぐことができます。
  2. リトライループ: forループを使って指定された回数だけリトライを行います。成功すれば即座に結果を返し、失敗した場合は再試行します。
  3. 失敗時の処理: リトライに失敗するたびに、警告を出力し、次のリトライまで待機します。すべてのリトライが失敗した場合は、最終的にエラーをスローします。

実装の利点

この実装により、リトライ回数を制御でき、過度なリトライやシステムへの負担を防ぎます。また、リトライが失敗するたびに一定の待機時間を設けることで、サーバーの一時的な負荷を軽減する効果もあります。

さらなるカスタマイズ

リトライ回数の上限や待機時間は、状況に応じて柔軟に調整可能です。例えば、指数バックオフ戦略を用いることで、リトライごとに待機時間を増加させることもできます。

async function retryableFetchWithBackoff(url: string, retries: number = 3, baseDelay: number = 1000): Promise<any> {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error('Failed to fetch data');
            }
            return await response.json();
        } catch (error) {
            const delay = baseDelay * Math.pow(2, i); // 指数バックオフ
            if (i < retries - 1) {
                console.warn(`Retry ${i + 1} failed. Retrying in ${delay}ms...`);
                await new Promise(resolve => setTimeout(resolve, delay));
            } else {
                console.error('Max retry limit reached with backoff.');
                throw error;
            }
        }
    }
}

このように、リトライ処理の待機時間をリトライごとに増加させることで、サーバーやネットワークに対する負荷を軽減し、リソースの効率的な使用が可能になります。

まとめ

リトライ回数に上限を設けることは、システムの安定性やリソース効率を保つために非常に重要です。TypeScriptを使って簡潔かつ型安全にリトライ処理を実装することで、エラーに強いアプリケーションを構築することができます。また、リトライ戦略を柔軟にカスタマイズすることで、より適切なエラーハンドリングが実現できます。

上限に達した場合のリトライ停止と通知

リトライ処理が設定した回数の上限に達した場合、処理を無理に継続せずに適切な対応を行うことが重要です。ここでは、リトライ回数が上限に達した場合のリトライ停止と、その際にユーザーやシステムへ通知する方法について説明します。

リトライ上限に達した場合の処理

リトライ処理が上限に達した場合、以下のような対応を行うことが考えられます:

  1. リトライ処理を停止して、システムが不必要に負荷をかけないようにする。
  2. ユーザーにエラーメッセージや通知を表示し、次のステップを案内する。
  3. システムや管理者にログを記録し、後で問題のトラブルシューティングができるようにする。

リトライ停止とエラーメッセージの表示例

以下は、リトライ上限に達した場合に、処理を停止してユーザーにエラーメッセージを通知する例です。

async function fetchDataWithRetryAndNotify(url: string, retries: number = 3): Promise<any> {
    try {
        return await retryableFetch(url, retries);
    } catch (error) {
        console.error('Failed after max retries:', error);
        notifyUser('データの取得に失敗しました。後でもう一度お試しください。');
        throw error; // エラーを再スローして、システム全体で扱えるようにする
    }
}

function notifyUser(message: string) {
    // Webアプリケーションであれば、UIにエラーメッセージを表示する
    alert(message); // シンプルなアラート表示の例
}

この例では、リトライ上限に達した後、ユーザーにエラーメッセージが表示されます。また、エラーは再スローされ、上位の処理でさらにエラーを処理することもできます。

エラー通知の方法

エラーメッセージの通知方法は、アプリケーションの種類や規模によって異なります。以下のような方法でユーザーにエラーを伝えることができます。

  • アラートやポップアップメッセージ: 上記の例のように、シンプルにブラウザのアラートで通知する方法です。これは小規模なアプリケーションやデバッグ用に適しています。
  • UIコンポーネントでの表示: 大規模なアプリケーションでは、画面内にエラーメッセージの表示エリアを設け、ユーザーにわかりやすく通知します。
  • ロギングやアナリティクスの利用: システム管理者にエラーログを通知するために、ロギングサービスやエラーレポートサービス(例: Sentry)を使うことも重要です。
function notifyUserWithUI(message: string) {
    const errorMessageElement = document.getElementById('error-message');
    if (errorMessageElement) {
        errorMessageElement.textContent = message;
        errorMessageElement.style.display = 'block'; // エラー表示エリアを可視化
    }
}

システムや管理者への通知

リトライの上限に達した場合、エラーログを記録することで、開発者や運用担当者がエラーの発生状況を把握しやすくなります。以下は、エラーログを記録する例です。

function logErrorToService(error: Error) {
    // 外部サービス(例: Sentry, Datadog)にエラーログを送信
    console.log(`Logging error: ${error.message}`);
    // エラーをロギングサービスに送信する処理
}

ログ記録に加えて、サーバーサイドで問題が発生した場合には、メールやチャットツール(例: Slack)を使って管理者に通知することも効果的です。

リトライ停止後の適切なフォローアップ

リトライが上限に達した際、ユーザーへの通知やエラーログの記録に加えて、フォールバック処理を実装することも検討すべきです。たとえば、キャッシュデータを使用する、代替リソースに切り替えるなど、可能な限りユーザーに不便を与えない対策を講じることができます。

まとめ

リトライ回数が上限に達した場合、システムの無駄な負荷を避けつつ、ユーザーに適切な通知を行うことが重要です。また、エラーをシステムに記録して、後で問題をトラブルシューティングできるようにすることで、サービスの信頼性を保ち、改善の余地を見つけやすくなります。

応用例:リトライ処理をカスタマイズする

リトライ処理は、単に固定回数試行するだけでなく、特定の条件に応じてカスタマイズすることで、より柔軟で効率的な実装が可能になります。ここでは、リトライ処理を応用して、状況に応じたカスタマイズ可能な仕組みを実装する方法について説明します。

応用例1: 条件に基づくリトライ

リトライの成否を単純な試行回数に基づけるだけではなく、リクエストの失敗理由に応じてリトライするかどうかを決定することができます。例えば、ネットワークエラーはリトライ対象だが、認証エラーはすぐに失敗と見なす場合です。

async function retryableFetchWithCondition(url: string, retries: number = 3): Promise<any> {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                if (response.status === 401) {
                    // 認証エラーの場合はリトライせず即座に失敗
                    throw new Error('Unauthorized access - stop retrying');
                }
                throw new Error('Request failed');
            }
            return await response.json();
        } catch (error) {
            if (error.message.includes('Unauthorized')) {
                throw error; // 認証エラーの場合、再試行せず終了
            }
            if (i < retries - 1) {
                console.warn(`Retry ${i + 1} due to network issue. Retrying...`);
            } else {
                throw error;
            }
        }
    }
}

この例では、ネットワークエラーや一時的な障害に対してはリトライを行いますが、認証エラー(HTTPステータス401)の場合は即座にリトライを中止します。これにより、無駄なリトライを防ぎ、特定のエラーに対して効率的に処理を行うことができます。

応用例2: 指数バックオフによるリトライ

通常のリトライ処理では一定の間隔で再試行しますが、指数バックオフを用いると、リトライの間隔がリトライ回数ごとに倍増していきます。これにより、サーバーへの負荷を減らしつつ、より効果的にリトライを行うことができます。

async function retryableFetchWithExponentialBackoff(url: string, retries: number = 3, baseDelay: number = 1000): Promise<any> {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error('Request failed');
            }
            return await response.json();
        } catch (error) {
            const delay = baseDelay * Math.pow(2, i); // リトライ間隔を指数的に増加
            if (i < retries - 1) {
                console.warn(`Retry ${i + 1} failed. Retrying in ${delay}ms...`);
                await new Promise(resolve => setTimeout(resolve, delay));
            } else {
                console.error('Max retry limit reached with exponential backoff.');
                throw error;
            }
        }
    }
}

この実装では、最初のリトライではbaseDelay(例えば1秒)の間隔を取り、次のリトライでは2倍、さらにその次では4倍と、リトライ間隔が指数的に増加します。これにより、サーバーへの過剰な負荷を避けつつ、ネットワークの一時的な問題に対応することができます。

応用例3: カスタムリトライポリシーの導入

アプリケーションによっては、リトライ処理の動作を柔軟に制御したい場合があります。例えば、リトライ回数や待機時間を外部から指定したり、リトライする条件をカスタマイズしたい場合です。以下は、リトライポリシーをオブジェクトとして管理することで、リトライ動作をカスタマイズ可能にする例です。

type RetryPolicy = {
    maxRetries: number;
    delayStrategy: (attempt: number) => number;
    shouldRetry: (error: Error) => boolean;
};

async function fetchDataWithCustomRetry(url: string, policy: RetryPolicy): Promise<any> {
    for (let attempt = 0; attempt < policy.maxRetries; attempt++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error('Request failed');
            }
            return await response.json();
        } catch (error) {
            if (!policy.shouldRetry(error) || attempt === policy.maxRetries - 1) {
                throw error;
            }
            const delay = policy.delayStrategy(attempt);
            console.warn(`Retry ${attempt + 1} failed. Retrying in ${delay}ms...`);
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
}

この実装では、RetryPolicyというオブジェクトを用意し、maxRetriesで最大リトライ回数、delayStrategyでリトライ間隔、shouldRetryでリトライするかどうかの判断を柔軟に指定できます。これにより、アプリケーションの要件に応じたカスタムリトライポリシーを簡単に実装できます。

const policy: RetryPolicy = {
    maxRetries: 5,
    delayStrategy: (attempt) => 1000 * Math.pow(2, attempt), // 指数バックオフ
    shouldRetry: (error) => !error.message.includes('Unauthorized'), // 認証エラー以外はリトライ
};

fetchDataWithCustomRetry('https://api.example.com/data', policy)
    .then(data => console.log('Data fetched successfully:', data))
    .catch(error => console.error('Failed to fetch data:', error.message));

まとめ

リトライ処理をカスタマイズすることで、単純なリトライを超えた柔軟なエラーハンドリングが可能になります。リトライ条件、待機時間、エラーの種類に応じたリトライ戦略を実装することで、アプリケーションの信頼性と効率性が向上します。TypeScriptを使って型安全なカスタムリトライ処理を構築することにより、メンテナンス性の高い、強力なエラーハンドリングを実現できます。

型安全性を保つためのユニットテスト

TypeScriptで型安全なリトライ処理やエラーハンドリングを実装する際、ユニットテストを行うことで、実装した処理が正しく機能していることを確認できます。ここでは、リトライ処理の型安全性をテストするための具体的な手法について説明します。

ユニットテストの重要性

リトライ処理では、成功・失敗の両方のケースを想定したテストが重要です。特に、リトライ回数が上限に達した場合や、型安全なエラーハンドリングが期待通りに機能しているかを確認することが不可欠です。ユニットテストによって、リトライ処理が予期しないバグを生じないように、動作の正当性を担保します。

Jestを使ったリトライ処理のテスト

ここでは、TypeScriptのプロジェクトでよく使われるテストフレームワークの一つであるJestを使って、リトライ処理のテストを実装します。

// retryableFetch.ts - リトライ処理の実装
export async function retryableFetch(url: string, retries: number = 3): Promise<any> {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error('Request failed');
            }
            return await response.json();
        } catch (error) {
            if (i === retries - 1) {
                throw error;
            }
        }
    }
}

次に、このretryableFetch関数をテストします。

// retryableFetch.test.ts - テストファイル
import { retryableFetch } from './retryableFetch';

// Jestでfetchをモック
global.fetch = jest.fn();

// テストケース1: 成功するケース
test('fetch succeeds on first attempt', async () => {
    (fetch as jest.Mock).mockResolvedValue({
        ok: true,
        json: async () => ({ data: 'success' }),
    });

    const result = await retryableFetch('https://api.example.com/data');
    expect(result).toEqual({ data: 'success' });
    expect(fetch).toHaveBeenCalledTimes(1); // 一度だけ呼ばれる
});

// テストケース2: リトライ後に成功するケース
test('fetch succeeds after retries', async () => {
    (fetch as jest.Mock)
        .mockRejectedValueOnce(new Error('Network error'))
        .mockResolvedValueOnce({
            ok: true,
            json: async () => ({ data: 'success after retry' }),
        });

    const result = await retryableFetch('https://api.example.com/data', 3);
    expect(result).toEqual({ data: 'success after retry' });
    expect(fetch).toHaveBeenCalledTimes(2); // 二度呼ばれる
});

// テストケース3: リトライ上限に達して失敗するケース
test('fetch fails after max retries', async () => {
    (fetch as jest.Mock).mockRejectedValue(new Error('Network error'));

    await expect(retryableFetch('https://api.example.com/data', 3)).rejects.toThrow('Network error');
    expect(fetch).toHaveBeenCalledTimes(3); // 最大リトライ回数
});

テストコード解説

  1. fetchのモック: Jestを使って、グローバルのfetch関数をモック化し、リトライ処理の動作をシミュレートします。これにより、実際のAPI呼び出しを行うことなく、テストを実施できます。
  2. テストケース1: retryableFetchが最初の試行で成功した場合のテストです。この場合、fetchは一度しか呼ばれません。
  3. テストケース2: 最初のリトライが失敗し、2回目のリトライで成功した場合のテストです。fetchは2回呼ばれることが期待されます。
  4. テストケース3: リトライ回数が上限に達し、最終的に失敗するケースのテストです。この場合、指定したリトライ回数分だけfetchが呼ばれます。

型安全なエラーハンドリングのテスト

TypeScriptの強力な型システムを使って、リトライ処理におけるエラーハンドリングが正しく機能しているかを検証することも重要です。たとえば、特定のエラーがスローされたときに正しい型が返されるかをテストします。

// 型エラーをテストするケース
test('throws correct error type on failure', async () => {
    (fetch as jest.Mock).mockRejectedValue(new Error('Request failed'));

    await expect(retryableFetch('https://api.example.com/data', 3)).rejects.toThrow(Error);
});

このテストでは、retryableFetchがエラーをスローした際、Error型のエラーがスローされることを確認しています。これにより、型安全性が担保されていることを確認できます。

エッジケースのテスト

また、リトライ回数が0の場合や、リトライ回数の上限を極端に大きくした場合など、エッジケースをテストすることも重要です。

// リトライ回数が0の場合
test('no retries when retries is set to 0', async () => {
    (fetch as jest.Mock).mockRejectedValue(new Error('Request failed'));

    await expect(retryableFetch('https://api.example.com/data', 0)).rejects.toThrow('Request failed');
    expect(fetch).toHaveBeenCalledTimes(1); // 一度のみ呼ばれる
});

まとめ

型安全なリトライ処理を実装する上で、ユニットテストは非常に重要です。TypeScriptの型システムを利用しながら、エラー処理やリトライ回数、成功・失敗のケースを網羅的にテストすることで、信頼性の高いコードを維持できます。また、テストを通じてバグの早期発見が可能になり、将来的なメンテナンス性も向上します。

まとめ

本記事では、TypeScriptにおけるリトライ処理と型安全なエラーハンドリングの重要性と実装方法について解説しました。リトライ回数の制限や、条件に基づくリトライ、指数バックオフ、カスタマイズ可能なリトライポリシーの導入によって、より柔軟かつ効率的なリトライ処理が実現できます。さらに、型安全性を保つためのユニットテストを通じて、信頼性の高いエラーハンドリングを構築することが可能です。

コメント

コメントする

目次