TypeScriptでリトライ処理をデコレーターで実装する方法と応用例

TypeScriptでリトライ処理を実装する場合、従来の方法では関数内にロジックを直接書く必要がありますが、デコレーターを使うことで、リトライ処理をより簡潔に、再利用可能な形で実装することが可能です。この記事では、TypeScriptのデコレーター機能を活用して、エラーハンドリングを効率化し、コードの可読性やメンテナンス性を向上させる方法を解説します。デコレーターを使ったリトライ処理は、特に非同期処理や外部APIとの通信時に役立つため、実際の利用例も交えながら詳しく見ていきます。

目次

リトライ処理の基本

リトライ処理とは、プログラムが一度失敗した処理を再試行する仕組みのことを指します。これは、ネットワークエラーや一時的なサーバーダウンなど、一時的な障害が原因で処理が失敗した際に有効です。例えば、API呼び出しがタイムアウトした場合に数回リトライすることで、エラーを回避し、正常に処理を進められる可能性を高めます。

リトライ処理が必要なケース

リトライ処理は、次のような場面でよく使用されます。

  • ネットワーク通信:不安定なネットワークでのAPI呼び出し
  • 外部サービスの依存:一時的に利用不可能な外部サービスのリトライ
  • データベース接続:接続エラー時の再接続試行

こうしたケースでは、エラーが発生してもすぐに諦めず、一定回数再試行することで、システムの安定性を向上させることができます。

TypeScriptにおけるデコレーターの基礎

デコレーターは、TypeScriptのクラスやメソッドに機能を付与する強力な機能です。デコレーターを使用することで、関数やクラスに共通のロジックを追加したり、振る舞いを変更したりすることが容易になります。デコレーターは、特にコードの再利用性を高める上で非常に有効です。

デコレーターの基本構文

TypeScriptでデコレーターを使うには、まずデコレーター関数を定義します。デコレーターは、クラスやメソッドの上に@マークを付けて使用されます。例えば、メソッドにロジックを追加するデコレーターの基本的な構文は以下の通りです。

function MyDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // メソッドをラップする処理
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
        console.log('メソッド呼び出し前');
        const result = originalMethod.apply(this, args);
        console.log('メソッド呼び出し後');
        return result;
    };

    return descriptor;
}

上記の例では、MyDecoratorが指定されたメソッドの前後にログを出力するような追加機能を持たせています。

デコレーターの種類

TypeScriptのデコレーターには以下の種類があります。

  • クラスデコレーター:クラス全体に適用するデコレーター
  • メソッドデコレーター:メソッドの前後に処理を追加するデコレーター
  • アクセサデコレーター:getterやsetterに対するデコレーター
  • プロパティデコレーター:クラスのプロパティに適用するデコレーター

本記事では、特にリトライ処理をメソッドに付与する「メソッドデコレーター」を使用して、リトライロジックを実装します。

リトライ処理をデコレーターで実装する

TypeScriptでリトライ処理をデコレーターを使って実装することで、コードの冗長性を減らし、リトライロジックを簡潔に管理できます。ここでは、メソッドデコレーターを使って、指定した回数まで処理を再試行するリトライ機能を実装します。

デコレーターによるリトライ処理の実装

リトライ処理を実現するために、メソッドをラップしてエラーが発生した際に再試行するデコレーターを作成します。以下は、その基本的な実装例です。

function Retry(retries: number) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;

        descriptor.value = async function (...args: any[]) {
            let attempts = 0;
            while (attempts < retries) {
                try {
                    // 元のメソッドを実行
                    return await originalMethod.apply(this, args);
                } catch (error) {
                    attempts++;
                    if (attempts >= retries) {
                        console.error(`Failed after ${attempts} attempts:`, error);
                        throw error; // リトライ回数を超えた場合はエラーを投げる
                    }
                    console.log(`Retrying... (${attempts}/${retries})`);
                }
            }
        };

        return descriptor;
    };
}

このRetryデコレーターは、指定した回数までエラーが発生した場合にリトライする仕組みを持っています。リトライ回数をretriesとして指定し、エラーが発生した場合には、リトライ処理を繰り返します。

リトライデコレーターの使用例

以下の例では、外部APIからデータを取得する処理にリトライデコレーターを適用しています。

class ApiService {
    @Retry(3) // リトライ回数を3回に設定
    async fetchData() {
        console.log('API呼び出し中...');
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
            throw new Error('APIリクエスト失敗');
        }
        return await response.json();
    }
}

const service = new ApiService();
service.fetchData().catch(error => console.error('最終的に失敗しました:', error));

この例では、fetchDataメソッドに@Retry(3)デコレーターを使用し、API呼び出しが失敗した場合、最大3回まで再試行するようになっています。

実装上の注意点

リトライ処理をデコレーターで実装する際には、いくつかの注意点を考慮する必要があります。無制限にリトライを行うことや、適切なエラーハンドリングを行わない場合、システムに不具合が発生する可能性があります。ここでは、リトライ処理を安全かつ効果的に行うための重要なポイントについて解説します。

リトライ回数の設定

リトライ回数を過度に多く設定すると、システムやネットワークに過負荷をかけるリスクがあります。一般的には、リトライ回数は2〜5回程度に設定するのが望ましいです。また、APIや外部サービスに負荷をかけないように、リトライの間に一定の待機時間(エクスポネンシャルバックオフ)を挟むことも重要です。

function RetryWithDelay(retries: number, delay: number) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;

        descriptor.value = async function (...args: any[]) {
            let attempts = 0;
            while (attempts < retries) {
                try {
                    return await originalMethod.apply(this, args);
                } catch (error) {
                    attempts++;
                    if (attempts >= retries) {
                        throw error; // リトライ回数を超えた場合
                    }
                    console.log(`Retrying in ${delay}ms... (${attempts}/${retries})`);
                    await new Promise(res => setTimeout(res, delay)); // 指定時間待機
                }
            }
        };

        return descriptor;
    };
}

この例では、リトライの間にdelayミリ秒の待機時間を挟むようにしています。これにより、エラーが発生した際にサーバーやネットワークへの負荷を軽減しつつ、リトライが行われます。

例外処理の扱い

リトライ処理の実装では、例外処理を適切に扱うことが不可欠です。全ての例外を無条件にリトライするのではなく、リトライすべき例外とそうでない例外を区別することが重要です。例えば、ネットワークエラーやタイムアウトはリトライの対象になりますが、認証エラーや入力のバリデーションエラーはリトライしても改善しないため、これらは即座にエラーとして処理すべきです。

function RetryForSpecificErrors(retries: number, retryableErrors: string[]) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;

        descriptor.value = async function (...args: any[]) {
            let attempts = 0;
            while (attempts < retries) {
                try {
                    return await originalMethod.apply(this, args);
                } catch (error) {
                    if (!retryableErrors.includes(error.message)) {
                        throw error; // リトライ対象外のエラーの場合、即座に例外を投げる
                    }
                    attempts++;
                    if (attempts >= retries) {
                        throw error; // リトライ回数を超えた場合
                    }
                    console.log(`Retrying... (${attempts}/${retries})`);
                }
            }
        };

        return descriptor;
    };
}

この実装では、retryableErrorsとして指定したエラーだけがリトライの対象となり、それ以外のエラーは即座に処理されます。

リトライ処理の終了条件

リトライ処理には終了条件を設定することが必要です。リトライ回数が上限に達した場合や、リトライ対象外のエラーが発生した場合は、速やかに処理を中断し、適切にエラーを報告することが求められます。

非同期処理とリトライの連携

TypeScriptでリトライ処理を実装する際には、非同期処理(特にPromiseasync/awaitを使用する場合)との連携が重要です。API呼び出しやデータベースクエリなどの非同期処理では、リトライ処理を適切に実装することで、エラー発生時の柔軟な対応が可能となります。ここでは、非同期処理におけるリトライのポイントと実装例を紹介します。

非同期関数でのリトライ処理

非同期関数にリトライ処理を適用するには、通常の関数とは異なりawaitPromiseを使用して、非同期処理が完了するまで待機しつつ、エラー時に再試行するように設計する必要があります。以下は、非同期関数でリトライ処理を行う場合の例です。

function RetryAsync(retries: number, delay: number = 0) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;

        descriptor.value = async function (...args: any[]) {
            let attempts = 0;
            while (attempts < retries) {
                try {
                    return await originalMethod.apply(this, args);
                } catch (error) {
                    attempts++;
                    if (attempts >= retries) {
                        throw error; // 最大リトライ回数に達した場合
                    }
                    console.log(`Retrying after ${delay}ms... (${attempts}/${retries})`);
                    if (delay > 0) {
                        await new Promise(res => setTimeout(res, delay)); // リトライ前の遅延
                    }
                }
            }
        };

        return descriptor;
    };
}

この例では、非同期処理が失敗した場合に指定回数まで再試行し、再試行間に遅延を挟むことができます。リトライ回数が指定の上限に達した場合、エラーが発生し処理は中断されます。

非同期処理のリトライ例

次に、RetryAsyncデコレーターを使用して、非同期処理のAPI呼び出しでリトライ処理を行う例を紹介します。

class DataService {
    @RetryAsync(3, 1000) // 3回リトライし、各リトライ間に1秒の遅延を挟む
    async getDataFromApi() {
        console.log('Fetching data from API...');
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
            throw new Error('API Request Failed');
        }
        return await response.json();
    }
}

const service = new DataService();
service.getDataFromApi().then(data => {
    console.log('Data:', data);
}).catch(error => {
    console.error('Failed after retries:', error);
});

この例では、getDataFromApiメソッドがAPI呼び出しに失敗した場合、最大3回までリトライし、各リトライ間に1秒の遅延を設けています。これにより、短時間のネットワーク障害などであれば、リトライにより問題を回避できる可能性が高まります。

非同期リトライ時の考慮点

非同期処理におけるリトライ処理では、いくつかの点に注意が必要です。

リトライ間の遅延

リトライを行う際には、リトライ間に適切な遅延を設けることが重要です。特に、即座に再試行することは、外部APIやデータベースに過剰な負荷をかける可能性があります。遅延を挟むことで、サービス側の負荷を軽減し、処理の成功率を高めることができます。

エクスポネンシャルバックオフ

遅延時間を徐々に増やしていく「エクスポネンシャルバックオフ」も有効な手法です。リトライ間の待機時間を指数関数的に増やすことで、外部サービスに優しく、リソースの無駄を防ぎつつ、リトライを行うことが可能です。

async function wait(ms: number) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function retryWithExponentialBackoff(fn: () => Promise<any>, retries: number, delay: number) {
    for (let attempt = 1; attempt <= retries; attempt++) {
        try {
            return await fn();
        } catch (error) {
            if (attempt === retries) throw error;
            const waitTime = delay * Math.pow(2, attempt);
            console.log(`Retrying in ${waitTime}ms...`);
            await wait(waitTime);
        }
    }
}

このようなリトライ戦略を導入することで、より効果的なエラーハンドリングと安定した非同期処理が実現できます。

リトライ処理のパフォーマンス最適化

リトライ処理はエラーハンドリングにおいて非常に有効ですが、適切に実装しないとパフォーマンスの低下やリソースの浪費を引き起こす可能性があります。ここでは、TypeScriptでリトライ処理を行う際に、パフォーマンスを最適化するための考慮点と実践的な手法を紹介します。

リトライの上限回数とバランス

リトライ回数を過度に多く設定すると、無駄な計算リソースを消費し、パフォーマンスが低下します。API呼び出しやデータベースクエリなどの重い処理でリトライを多用すると、サーバーやネットワークの負荷が増加します。そのため、リトライ回数は適切な範囲に設定し、システム全体に過度な負荷をかけないようにすることが重要です。

@Retry(3) // 最大3回リトライ
async function fetchData() {
    // ここにAPI呼び出しなどの処理が入る
}

リトライ回数を3〜5回程度に制限し、無限リトライは避けることが推奨されます。また、リトライ回数をシステムや使用シナリオに応じて設定できるよう、柔軟性を持たせることも重要です。

エクスポネンシャルバックオフによる負荷軽減

リトライ間隔を一定に保つよりも、エクスポネンシャルバックオフを使用することで、処理の成功率を高めつつ、サーバーへの負荷を軽減できます。リトライ間隔を指数関数的に増やすことで、すぐに再試行せず、障害が回復する可能性を増やすアプローチです。これにより、無駄なリトライを減らし、全体のパフォーマンスを最適化できます。

function RetryWithBackoff(retries: number, initialDelay: number) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;

        descriptor.value = async function (...args: any[]) {
            let attempts = 0;
            let delay = initialDelay;

            while (attempts < retries) {
                try {
                    return await originalMethod.apply(this, args);
                } catch (error) {
                    attempts++;
                    if (attempts >= retries) {
                        throw error; // リトライ回数を超えた場合
                    }
                    console.log(`Retrying in ${delay}ms...`);
                    await new Promise(res => setTimeout(res, delay));
                    delay *= 2; // 遅延を倍増
                }
            }
        };

        return descriptor;
    };
}

エクスポネンシャルバックオフを使用すると、初回リトライでは短い遅延時間を設け、リトライのたびに遅延を増やすことでサーバーや外部サービスへの負担を最小限に抑えながら再試行できます。

リソースの適切な開放とエラーログの記録

リトライ処理が失敗した場合、メモリリークや不要な接続が残らないよう、リソースを適切に開放することが重要です。特に、データベースやファイルシステムなどのリソースを扱う場合、処理が失敗した時点で接続を閉じたり、再試行後にリソースが不要な場合は確実に解放するようにします。

また、リトライが行われた際のエラーログを記録し、後に解析できるようにしておくことも重要です。これにより、システムのエラー傾向やリトライが多発している箇所を特定し、さらなるパフォーマンス改善に役立てることができます。

タイムアウトの設定

リトライ処理が完了しないまま長時間にわたると、アプリケーション全体のパフォーマンスに悪影響を及ぼします。特に、ネットワーク通信や外部APIを使用する場合には、各リクエストに適切なタイムアウトを設定することで、長時間の待機を回避し、効率的なリトライを行うことができます。

async function fetchDataWithTimeout(url: string, timeout: number) {
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), timeout);

    try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error('Network response was not ok');
        return await response.json();
    } finally {
        clearTimeout(id);
    }
}

このように、適切なタイムアウトとリトライ処理を組み合わせることで、システムのパフォーマンスを保ちつつ安定した処理を実現できます。

キャッシュの活用

同じ処理を何度もリトライする際に、処理の成功結果や失敗結果をキャッシュしておくことで、同じリクエストの重複を避け、リトライによる不要な負荷を抑えることができます。キャッシュを利用して、一定期間内に同じリクエストがあれば、キャッシュされた結果を返すように設計するのもパフォーマンス向上の有効な手段です。

パフォーマンス最適化を考慮したリトライ処理を導入することで、システムの負荷を最小限に抑えつつ、柔軟なエラーハンドリングを行えるようになります。

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

リトライ処理を効果的に実装するためには、エラーハンドリングのベストプラクティスを理解しておくことが重要です。エラーハンドリングが適切に行われないと、リトライ処理自体が機能しないか、逆にシステム全体に悪影響を及ぼす可能性があります。ここでは、リトライ処理に関連するエラーハンドリングのベストプラクティスについて説明します。

リトライ対象のエラーを限定する

すべてのエラーに対してリトライを行うのは、無駄なリソースの消費につながります。リトライするべきエラーと、即座に失敗として扱うべきエラーを明確に区別する必要があります。たとえば、ネットワーク関連のエラーやタイムアウトは一時的な問題である可能性が高いため、リトライの対象にするのが有効です。一方で、認証エラーやバリデーションエラーはリトライしても解決しないため、即座に失敗として処理すべきです。

function isRetryableError(error: any): boolean {
    const retryableErrors = ['NetworkError', 'TimeoutError'];
    return retryableErrors.includes(error.name);
}

このように、リトライ可能なエラーを事前に定義しておくことで、無駄なリトライを防ぎます。

エラーのロギングとモニタリング

エラーが発生した際には、その内容を適切にログに記録し、後で分析できるようにすることが重要です。エラーハンドリングとリトライ処理が正しく機能しているかを確認し、必要に応じて改善するために、エラー発生時の詳細な情報を収集しておくことが推奨されます。

async function handleError(error: any, attempt: number) {
    console.error(`Error occurred on attempt ${attempt}:`, error);
}

エラーログには、発生したエラーの種類、メッセージ、リトライ回数などを含めることで、トラブルシューティングを迅速に行えるようにします。

エラーフォールバック戦略

リトライ回数を超えてもエラーが解決しない場合には、フォールバック戦略を用意することが重要です。フォールバックとは、最終的な失敗時に代替処理を行うことで、ユーザー体験の改善やシステムの回復力を高める手法です。例えば、API呼び出しが失敗した場合にはキャッシュされたデータを返す、もしくは代替のAPIを利用する、といった対応が考えられます。

async function fetchDataWithFallback() {
    try {
        return await fetchData();
    } catch (error) {
        console.log('Fallback to cached data due to error:', error);
        return getCachedData();
    }
}

このようなフォールバック戦略を導入することで、リトライ処理が失敗した場合でもシステム全体が正常に動作するように設計できます。

リトライとタイムアウトの併用

リトライ処理を行う際には、リトライだけに頼らず、処理全体のタイムアウトを設定することも重要です。リトライが繰り返されて長時間処理が止まってしまうのを防ぎ、一定時間内に解決しない場合には速やかにエラーメッセージをユーザーに返すように設計します。

function withTimeout(promise: Promise<any>, ms: number) {
    const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms));
    return Promise.race([promise, timeout]);
}

タイムアウトとリトライを組み合わせることで、非効率なリトライ処理を防ぎ、迅速なエラーハンドリングを実現できます。

ユーザーへの適切な通知

リトライ処理が行われていることや、最終的に処理が失敗した場合には、ユーザーに適切に通知することが重要です。特にUIを伴うアプリケーションでは、ユーザーがリトライ中のステータスを確認できるようにし、失敗時には明確なエラーメッセージを表示することで、ユーザー体験の向上を図ることができます。

async function retryWithNotification() {
    try {
        await fetchData();
    } catch (error) {
        displayErrorMessage('Data could not be retrieved. Please try again later.');
    }
}

ユーザーに対して適切なフィードバックを提供することで、システム障害時の混乱を最小限に抑えます。

エラーハンドリングを正しく実装することで、リトライ処理がより効果的に機能し、システムの安定性やパフォーマンスが向上します。

応用例:API呼び出しでのリトライ処理

API呼び出しは、ネットワークの不安定さや外部サービスの一時的なダウンにより、時折失敗することがあります。このような場合、リトライ処理を適切に実装することで、エラーの影響を最小限に抑え、システムの信頼性を向上させることができます。ここでは、API呼び出しにおけるリトライ処理の具体的な応用例を紹介します。

API呼び出しのリトライ処理の例

以下の例では、リトライ処理をデコレーターで実装し、外部APIを呼び出す際にネットワークエラーが発生した場合、自動的に再試行するように設定しています。

function RetryApi(retries: number, delay: number = 1000) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;

        descriptor.value = async function (...args: any[]) {
            let attempts = 0;
            while (attempts < retries) {
                try {
                    return await originalMethod.apply(this, args);
                } catch (error) {
                    attempts++;
                    if (attempts >= retries || error.name !== 'NetworkError') {
                        throw error; // リトライ対象外のエラー、またはリトライ回数が上限に達した場合
                    }
                    console.log(`Retrying API call (${attempts}/${retries}) in ${delay}ms...`);
                    await new Promise(res => setTimeout(res, delay));
                }
            }
        };

        return descriptor;
    };
}

class ApiService {
    @RetryApi(3, 2000) // 最大3回のリトライ、リトライ間に2秒の遅延
    async fetchData() {
        console.log('Fetching data from API...');
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
            throw new Error('API Request Failed');
        }
        return await response.json();
    }
}

const apiService = new ApiService();
apiService.fetchData().then(data => {
    console.log('Data:', data);
}).catch(error => {
    console.error('API call failed after retries:', error);
});

この例では、RetryApiデコレーターが、API呼び出しが失敗した場合に最大3回まで再試行し、各リトライの間に2秒の待機時間を設けています。ネットワークエラーなどの一時的な問題が発生した場合でも、適切にリトライ処理が行われ、再度APIが正常に応答する可能性を高めます。

成功と失敗のシナリオ

リトライ処理を適用することで、以下のようなシナリオで問題が解決する可能性があります。

成功シナリオ

  • 一時的なネットワークの不安定:ネットワークが一時的に不安定な場合、数秒後には接続が回復し、API呼び出しが成功することが多くあります。リトライ処理を導入することで、これらの一時的なエラーを回避できます。
  • 外部サービスの短時間ダウン:外部APIが短時間だけ利用できなくなる場合、リトライを行うことで、サービスが復旧次第正常に処理を再開できます。

失敗シナリオ

  • 認証エラーやバリデーションエラー:これらのエラーはリトライしても改善しないため、リトライせず即座にエラーとして処理するべきです。上記のコードでは、NetworkError以外のエラーが発生した場合、リトライせずに即座に例外がスローされます。
  • 外部APIの長期停止:API自体が長期間にわたって停止している場合、リトライ回数が尽きても解決しないため、適切にフォールバック戦略を用意しておく必要があります。

応用:異なるエンドポイントへのリトライ

APIが複数のエンドポイントを持っている場合、特定のエンドポイントが利用できない場合に別のエンドポイントに自動的にフォールバックすることも可能です。例えば、地域別にAPIサーバーが分かれている場合、一つのサーバーが利用できない場合でも他のサーバーにリクエストを送ることで、処理の成功率を高めることができます。

async function fetchWithFallback() {
    const endpoints = [
        'https://api1.example.com/data',
        'https://api2.example.com/data',
        'https://api3.example.com/data'
    ];

    for (let i = 0; i < endpoints.length; i++) {
        try {
            const response = await fetch(endpoints[i]);
            if (response.ok) {
                return await response.json();
            }
        } catch (error) {
            console.error(`Failed to fetch from ${endpoints[i]}:`, error);
            if (i === endpoints.length - 1) {
                throw new Error('All endpoints failed');
            }
        }
    }
}

このように複数のエンドポイントを使い分けることで、単一のAPIサーバーのダウン時にもサービスを継続させることが可能です。

まとめ

API呼び出しにおけるリトライ処理は、システムの安定性を向上させ、外部サービスとのやり取りが失敗した際にも柔軟に対処するための重要な手段です。適切なリトライ回数や遅延時間を設定し、さらにエラーの種類に応じた処理を行うことで、より信頼性の高いシステムを構築できます。

リトライ処理に関するユニットテストの書き方

リトライ処理を実装した後、その機能が正しく動作しているかを確認するためにユニットテストを行うことは非常に重要です。特にリトライ処理はエラーが発生した状況での挙動がポイントとなるため、正確なテストが求められます。ここでは、リトライ処理に関するテストの書き方と、確認すべき重要なポイントを解説します。

リトライ処理をテストする際のポイント

リトライ処理のテストでは、以下の点に注意してテストケースを設計します。

  1. リトライ回数の検証:指定した回数までリトライされることを確認します。
  2. エラーのシナリオテスト:リトライ対象のエラーが発生した場合と、対象外のエラーが発生した場合の動作を確認します。
  3. リトライ成功後の処理:リトライにより処理が成功した場合、正常に結果が返されるかを確認します。
  4. 最終的な失敗:リトライ回数を超えてもエラーが解決しない場合、エラーが適切にスローされるかを確認します。

ユニットテストの実装例

以下は、リトライ処理をテストするためのユニットテストの例です。このテストでは、jestというJavaScript/TypeScript向けのテスティングフレームワークを使用しています。

import { Retry } from './retry-decorator'; // 実装済みのリトライデコレーターをインポート
import { jest } from '@jest/globals'; // jestのインポート

class ApiService {
    @Retry(3)
    async fetchData() {
        return await someExternalApiCall();
    }
}

describe('Retry Decorator', () => {
    let apiService: ApiService;
    let mockApiCall: jest.Mock;

    beforeEach(() => {
        apiService = new ApiService();
        mockApiCall = jest.fn();
        // テスト用に関数のモックを差し替え
        (apiService.fetchData as jest.Mock) = mockApiCall;
    });

    it('should retry the method 3 times on failure', async () => {
        mockApiCall.mockRejectedValue(new Error('NetworkError')); // すべての呼び出しでエラーを返す

        try {
            await apiService.fetchData();
        } catch (error) {
            // 期待されるエラーメッセージが含まれているか確認
            expect(error.message).toBe('NetworkError');
        }

        // メソッドが3回リトライされたことを確認
        expect(mockApiCall).toHaveBeenCalledTimes(3);
    });

    it('should stop retrying after a successful call', async () => {
        mockApiCall
            .mockRejectedValueOnce(new Error('NetworkError')) // 最初の1回はエラー
            .mockResolvedValueOnce({ data: 'success' });      // 2回目は成功

        const result = await apiService.fetchData();

        // メソッドが2回呼び出されたことを確認
        expect(mockApiCall).toHaveBeenCalledTimes(2);
        expect(result).toEqual({ data: 'success' });
    });

    it('should throw the error if retry count is exceeded', async () => {
        mockApiCall.mockRejectedValue(new Error('PermanentError')); // 永続的なエラーを返す

        try {
            await apiService.fetchData();
        } catch (error) {
            // リトライ回数を超えた場合、エラーがスローされることを確認
            expect(error.message).toBe('PermanentError');
        }

        // 3回リトライ後にエラーをスローしたことを確認
        expect(mockApiCall).toHaveBeenCalledTimes(3);
    });
});

このテスト例では、次の点を確認しています:

  1. リトライ回数の確認:API呼び出しが失敗した際に、指定した回数までリトライが実行されているか。
  2. 成功時にリトライが停止するか:エラーが発生しても、途中で成功した場合は、そこでリトライが停止し、結果が返されることを確認しています。
  3. 失敗時のエラーハンドリング:リトライがすべて失敗した場合に、適切にエラーがスローされることを確認しています。

非同期処理のテストにおける注意点

非同期処理のリトライをテストする際には、次の点にも注意が必要です。

  • モック関数の使用:実際のAPIや非同期処理をテストする場合は、テスト環境でモック関数を使用し、実際のネットワーク通信を行わないようにします。これにより、テストが安定し、外部要因に左右されない結果が得られます。
  • 遅延処理の考慮:リトライ間の遅延をシミュレートする場合、jest.useFakeTimers()などの機能を使って、時間の進行をシミュレートすることができます。これにより、遅延処理を含むリトライ処理の動作も正確にテスト可能です。
jest.useFakeTimers();

it('should delay between retries', async () => {
    mockApiCall.mockRejectedValue(new Error('NetworkError'));

    const fetchPromise = apiService.fetchData();

    // リトライ前の遅延時間を進める
    jest.advanceTimersByTime(2000); // 2秒進める
    await fetchPromise;

    expect(mockApiCall).toHaveBeenCalledTimes(3); // 遅延後もリトライが行われたことを確認
});

リトライ処理テストのベストプラクティス

  • 例外的な状況をシミュレート:ネットワークエラーやタイムアウト、認証エラーなど、リトライ対象となるエラーや非対象のエラーの両方をシミュレートし、それぞれの挙動を確認する。
  • 処理の成功・失敗両方をテスト:リトライ後に成功するケース、すべて失敗するケースの両方を網羅する。
  • パフォーマンステスト:リトライ処理によるパフォーマンスへの影響がないか、処理時間やリソースの使用状況を監視する。

このようなテストケースを通じて、リトライ処理が意図通りに動作するかをしっかりと検証することが重要です。

まとめ

本記事では、TypeScriptでリトライ処理をデコレーターを使って実装する方法を紹介しました。リトライ処理の基本から、非同期処理との連携、パフォーマンス最適化、さらにAPI呼び出しへの応用例までを網羅し、実装における注意点やベストプラクティスを解説しました。ユニットテストによるリトライ処理の確認方法も取り上げ、実装の正確性を保つためのテクニックも学びました。デコレーターを用いることで、リトライ処理が簡潔で再利用可能な形で実装でき、より信頼性の高いシステム構築に役立つでしょう。

コメント

コメントする

目次