TypeScriptで外部APIエラー時に適用するリトライ戦略の型定義

TypeScriptを使用した外部API呼び出しにおいて、ネットワークの不安定さやサーバー側の一時的な問題によりエラーが発生することは避けられません。そのため、エラー発生時に適切なリトライ戦略を設計することが重要です。特に、APIリクエストが失敗した際に自動的に再試行することで、エラーが一時的なものである場合でも、アプリケーションの安定性を確保できます。本記事では、TypeScriptでのエラーハンドリングとリトライ戦略の型定義について、基本的な考え方から具体的な実装方法まで詳しく解説します。

目次

APIエラー発生時のリトライの重要性

APIを利用するアプリケーションでは、エラーが発生する可能性は常に存在します。サーバー側の一時的な障害、ネットワークの不具合、あるいはタイムアウトなど、さまざまな要因でAPIの呼び出しが失敗することがあります。これらのエラーに対して適切に対応するためには、リトライ戦略が欠かせません。

安定したシステム運用のためのリトライ戦略

APIエラーが発生した場合、リトライ戦略を実装することで、システムがエラーの影響を最小限に抑え、ユーザーにとってのサービス中断を防ぎます。リトライを行うことで、問題が一時的なものであれば再試行時に成功する可能性が高まります。

ユーザー体験の向上

エラーハンドリングを適切に行い、失敗したAPI呼び出しを再試行することで、ユーザーがエラーを感じにくくなり、アプリケーションの信頼性が向上します。特に、重要なデータの取得や送信が失敗した場合、ユーザーは手動でリロードや再試行を行うことなく、スムーズに処理が再開されるのが理想的です。

リトライ戦略の基本概念

リトライ戦略とは、API呼び出しが失敗した際に、一定の条件や間隔に基づいて自動的に再試行を行う方法を指します。適切なリトライ戦略を設計することで、エラーの影響を最小限に抑え、システムの信頼性を向上させることができます。

リトライ戦略の主なパターン

リトライ戦略にはいくつかの一般的なパターンがあります。代表的なものを以下に示します。

固定間隔リトライ

リトライ間隔を一定時間に設定し、失敗後に同じ時間間隔で再試行する方法です。この方法は実装が簡単で、安定したAPI呼び出しに対して効果的です。

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

リトライごとに待機時間を指数関数的に増加させる方法です。この戦略はサーバーへの過負荷を防ぐため、APIサーバーが過負荷状態でエラーを返す際に有効です。例えば、最初のリトライは1秒後、次は2秒後、さらにその次は4秒後、と待機時間を増やしていきます。

ランダム化されたエクスポネンシャルバックオフ

エクスポネンシャルバックオフにランダム要素を加えた方法で、サーバーに対する同時アクセスを防ぎ、スパイクを避けるために有効です。特に多数のクライアントが同時にリトライを行う場面で役立ちます。

リトライ戦略設計のポイント

最大リトライ回数

リトライの回数には上限を設けることが重要です。無限にリトライを繰り返すと、リソースが無駄になり、システム全体に悪影響を及ぼす可能性があります。

リトライの条件

エラーが発生した際に、すべての場合でリトライするわけではありません。特定のステータスコード(たとえば、500系のサーバーエラーや503 Service Unavailableなど)に対してのみリトライを行う設計が望ましいです。

TypeScriptでのリトライ戦略の実装方法

TypeScriptでは、Promiseやasync/awaitを活用して、エラー発生時にリトライするロジックを簡単に実装できます。ここでは、基本的なリトライ戦略を組み込んだ関数を実装し、エラー時に自動的に再試行する方法を説明します。

リトライ可能なAPI呼び出しの関数

まず、リトライを行うAPI呼び出しの基本構造を定義します。TypeScriptで非同期処理を扱うため、Promiseとasync/awaitを用いて実装します。

async function retryApiCall<T>(
  apiCall: () => Promise<T>,
  retries: number,
  delay: number
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }
    console.log(`リトライ回数残り: ${retries}, ${delay}ms 待機中...`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryApiCall(apiCall, retries - 1, delay);
  }
}

この関数では、apiCallという外部APIを呼び出す関数を受け取り、指定されたリトライ回数と遅延時間を設定します。API呼び出しが失敗した場合、指定した遅延時間だけ待機してからリトライし、成功するか、リトライ回数が0になるまで再試行を続けます。

使用例

次に、このリトライ関数を使って実際にAPI呼び出しを行う例を示します。ここでは、簡単なAPIリクエストを想定しています。

async function fetchData() {
  const apiCall = () => fetch("https://example.com/api/data").then(response => {
    if (!response.ok) {
      throw new Error("APIエラー");
    }
    return response.json();
  });

  try {
    const data = await retryApiCall(apiCall, 3, 1000); // 3回リトライ、1秒間隔
    console.log("データ取得成功:", data);
  } catch (error) {
    console.error("API呼び出しが失敗しました:", error);
  }
}

fetchData();

この例では、fetchData関数がAPIを呼び出し、エラーが発生した場合に3回までリトライします。それぞれのリトライ間隔は1秒に設定されています。

リトライ戦略の利点

このリトライ戦略により、外部APIの一時的なエラーに対して柔軟に対応できます。APIが一時的にダウンしていても、ユーザーにすぐにエラーを返すのではなく、一定の時間を待って再試行することで、アプリケーションの安定性が向上します。

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

エクスポネンシャルバックオフは、リトライ戦略の中でも特に効果的な手法です。リトライするごとに待機時間を指数関数的に増加させることで、サーバーへの負担を軽減し、成功率を向上させます。次に、TypeScriptを使ったエクスポネンシャルバックオフの実装方法について詳しく説明します。

エクスポネンシャルバックオフの仕組み

エクスポネンシャルバックオフでは、各リトライの間に待機する時間を次のように増やします。

  • 1回目のリトライ: 1秒待機
  • 2回目のリトライ: 2秒待機
  • 3回目のリトライ: 4秒待機
  • 4回目のリトライ: 8秒待機

このように、リトライの回数が増えるごとに待機時間が倍増していくため、サーバーへの負荷が抑えられます。また、これにランダム化を加えることで、同時アクセスのスパイクを避けることができます。

エクスポネンシャルバックオフの実装例

次に、TypeScriptを用いたエクスポネンシャルバックオフのリトライ戦略を実装してみましょう。

async function retryWithExponentialBackoff<T>(
  apiCall: () => Promise<T>,
  retries: number,
  baseDelay: number
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }

    const delay = baseDelay * Math.pow(2, retries);
    console.log(`リトライ残り: ${retries}, ${delay}ms 待機中...`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithExponentialBackoff(apiCall, retries - 1, baseDelay);
  }
}

この関数では、baseDelayという基準となる待機時間を指定し、リトライごとにその待機時間を指数関数的に増加させます。例えば、baseDelayを1000ミリ秒に設定すると、リトライごとに1000ms、2000ms、4000ms…と待機時間が増加します。

ランダム化を加えたエクスポネンシャルバックオフ

リトライタイミングがすべてのクライアントで同じだと、サーバーへの負荷が集中してしまう可能性があります。これを避けるために、待機時間にランダムな要素を加える方法があります。

async function retryWithJitter<T>(
  apiCall: () => Promise<T>,
  retries: number,
  baseDelay: number
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }

    const jitter = Math.random() * baseDelay;
    const delay = (baseDelay * Math.pow(2, retries)) + jitter;
    console.log(`リトライ残り: ${retries}, ${delay.toFixed(0)}ms 待機中...`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithJitter(apiCall, retries - 1, baseDelay);
  }
}

このretryWithJitter関数では、待機時間にランダムな値(ジッター)を加えています。これにより、クライアントが同じタイミングでリトライするのを避け、サーバーへの負荷を分散させることができます。

エクスポネンシャルバックオフの適用例

実際にこの関数を使ってAPI呼び出しを行う方法を示します。

async function fetchDataWithBackoff() {
  const apiCall = () => fetch("https://example.com/api/data").then(response => {
    if (!response.ok) {
      throw new Error("APIエラー");
    }
    return response.json();
  });

  try {
    const data = await retryWithExponentialBackoff(apiCall, 3, 1000); // 最大3回リトライ、初回1秒待機
    console.log("データ取得成功:", data);
  } catch (error) {
    console.error("API呼び出しが失敗しました:", error);
  }
}

fetchDataWithBackoff();

この例では、エクスポネンシャルバックオフを適用し、最大3回リトライを行います。初回のリトライでは1秒待機し、次回は2秒、さらにその次は4秒と待機時間が増えていきます。

利点と使用場面

エクスポネンシャルバックオフは、APIサーバーが一時的に過負荷状態やエラー状態である場合に特に有効です。一定の時間をおいて再試行することで、サーバーが回復する時間を与え、リクエストの成功率を上げることができます。また、サーバーリソースを効率的に利用するために、ジッターを加えることで、負荷の分散を図ることも可能です。

リトライ回数と間隔のカスタマイズ

リトライ戦略を効果的に運用するためには、リトライ回数や間隔をカスタマイズできるようにすることが重要です。システムの要件やAPIの特性に応じて、適切な回数や待機時間を設定することで、リソースを最適化しつつ、成功率を高めることができます。

リトライ回数の設定

リトライ回数は、APIやネットワークの性質に応じて柔軟に調整する必要があります。リトライ回数が少なすぎると、エラーから回復できない可能性が高くなり、逆に多すぎると無駄なリソース消費やシステムの応答性低下につながる恐れがあります。

以下は、リトライ回数をカスタマイズ可能にしたコード例です。

async function retryWithCustomAttempts<T>(
  apiCall: () => Promise<T>,
  retries: number = 3,
  delay: number = 1000
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }
    console.log(`リトライ残り: ${retries}, ${delay}ms 待機中...`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithCustomAttempts(apiCall, retries - 1, delay);
  }
}

この関数では、デフォルトで3回リトライし、1秒間隔で再試行しますが、関数呼び出し時に任意のリトライ回数と間隔を指定することも可能です。これにより、異なるAPIに対して異なる戦略を容易に適用できます。

リトライ間隔の調整

リトライの間隔もカスタマイズ可能にすることで、特定のAPIの負荷や応答速度に応じて柔軟なリトライが可能になります。エクスポネンシャルバックオフだけでなく、固定間隔でのリトライや、APIサーバーの状態に応じた調整も可能です。

たとえば、次の例では、一定の範囲内でランダムなリトライ間隔を設定する方法を紹介します。

async function retryWithRandomDelay<T>(
  apiCall: () => Promise<T>,
  retries: number,
  minDelay: number,
  maxDelay: number
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }

    const delay = Math.floor(Math.random() * (maxDelay - minDelay + 1)) + minDelay;
    console.log(`リトライ残り: ${retries}, ${delay}ms 待機中...`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithRandomDelay(apiCall, retries - 1, minDelay, maxDelay);
  }
}

この関数では、最小と最大の待機時間を指定し、その範囲内でランダムに待機時間を設定します。これにより、APIの状況に応じた柔軟なリトライを実現し、サーバーへの負担を軽減できます。

カスタマイズのメリット

リトライ回数や間隔をカスタマイズすることには以下のメリットがあります。

負荷のコントロール

APIサーバーの状態に応じてリトライ回数や間隔を調整することで、過負荷状態の回避が可能になります。リトライ間隔をランダム化したり、エクスポネンシャルバックオフを採用することで、リクエストの集中を防ぎ、安定したリクエストの成功率を維持できます。

柔軟なエラーハンドリング

システム全体のリソース管理や、APIごとの特性に応じたリトライ戦略を適用できるため、異なるAPIやエラー条件に柔軟に対応することができます。

使用例

次に、カスタマイズされたリトライ戦略を使用する例を示します。

async function fetchDataWithCustomRetry() {
  const apiCall = () => fetch("https://example.com/api/data").then(response => {
    if (!response.ok) {
      throw new Error("APIエラー");
    }
    return response.json();
  });

  try {
    const data = await retryWithRandomDelay(apiCall, 5, 500, 2000); // 最大5回リトライ、0.5秒から2秒の間隔
    console.log("データ取得成功:", data);
  } catch (error) {
    console.error("API呼び出しが失敗しました:", error);
  }
}

fetchDataWithCustomRetry();

このコードでは、0.5秒から2秒の間隔で最大5回までリトライするように設定しています。

API応答に基づいた動的なリトライ判断

API呼び出し時のエラーは、すべてリトライすれば良いわけではありません。例えば、認証エラーやクライアントサイドのバグ(HTTPステータスコード400系など)の場合はリトライしても失敗が続くため、無駄なリトライを避ける必要があります。逆に、サーバーの一時的な問題(HTTPステータスコード500系や503など)ではリトライが有効です。そのため、APIの応答に基づいて動的にリトライの可否を判断するロジックを実装することが重要です。

HTTPステータスコードによるリトライ判断

APIのレスポンスに含まれるHTTPステータスコードに基づいて、リトライするかどうかを動的に判断できます。例えば、以下のような方針で判断を行います。

  • 200系(成功): リトライ不要
  • 400系(クライアントエラー): リトライ不要(バグや無効なリクエスト)
  • 500系(サーバーエラー): リトライ対象(サーバーの一時的な問題)
  • 503(サービス利用不可): リトライ対象(サーバーの負荷による一時的な障害)

これを踏まえて、リトライ判断のロジックをTypeScriptで実装します。

動的リトライ判断の実装

以下のコードは、APIの応答に基づいて動的にリトライを判断する例です。リトライが必要な場合はリトライを行い、そうでない場合はエラーを返します。

async function retryWithStatusCheck<T>(
  apiCall: () => Promise<Response>,
  retries: number,
  delay: number
): Promise<T> {
  try {
    const response = await apiCall();

    if (response.ok) {
      return response.json(); // 成功の場合は結果を返す
    }

    if (response.status >= 500 || response.status === 503) {
      // サーバーエラーやサービス利用不可ならリトライ
      if (retries > 0) {
        console.log(`ステータスコード ${response.status} - リトライ中... 残り: ${retries}`);
        await new Promise(resolve => setTimeout(resolve, delay));
        return retryWithStatusCheck(apiCall, retries - 1, delay);
      }
    }

    throw new Error(`APIエラー: ステータスコード ${response.status}`);
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }
    console.log(`API呼び出しエラー: ${error.message}. リトライ残り: ${retries}`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithStatusCheck(apiCall, retries - 1, delay);
  }
}

この関数は、APIのステータスコードをチェックし、500系エラーや503の場合にリトライを行います。200系の成功応答が返された場合は即座に結果を返し、それ以外のエラー(400系など)はリトライせずエラーをスローします。

API応答に基づくカスタマイズ

さらに、リトライの判断基準を柔軟にするため、APIの応答内容や特定のエラーメッセージに基づいてリトライするかどうかを決定することも可能です。たとえば、特定のメッセージが含まれる場合にのみリトライするという条件を追加できます。

async function retryWithCustomStatusCheck<T>(
  apiCall: () => Promise<Response>,
  retries: number,
  delay: number
): Promise<T> {
  try {
    const response = await apiCall();

    if (response.ok) {
      return response.json();
    }

    const errorMessage = await response.text();

    if (response.status === 503 || errorMessage.includes("Temporary Issue")) {
      // 503エラーまたは特定のエラーメッセージに応じてリトライ
      if (retries > 0) {
        console.log(`エラーメッセージ: "${errorMessage}" - リトライ中... 残り: ${retries}`);
        await new Promise(resolve => setTimeout(resolve, delay));
        return retryWithCustomStatusCheck(apiCall, retries - 1, delay);
      }
    }

    throw new Error(`APIエラー: ${response.status} - ${errorMessage}`);
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }
    console.log(`エラー: ${error.message}. リトライ残り: ${retries}`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithCustomStatusCheck(apiCall, retries - 1, delay);
  }
}

この例では、HTTPステータスコードに加えて、レスポンス本文に特定のエラーメッセージが含まれている場合にもリトライを行います。これにより、APIサーバーの状態に応じた柔軟なリトライ戦略が可能になります。

動的なリトライ判断の利点

APIの応答に基づいて動的にリトライ判断を行うことで、以下の利点があります。

効率的なエラーハンドリング

無駄なリトライを避け、APIの状況に応じて的確にリトライを行うことで、リソースを効率的に利用できます。

ユーザー体験の向上

エラーによってユーザーに不要な遅延や負荷をかけず、サービスの中断を最小限に抑えることで、よりスムーズな体験を提供できます。

サーバー負荷の軽減

サーバーが過負荷状態の場合のみリトライを行い、他のケースでは不要なリトライを防ぐことで、サーバーへの負担を軽減します。

リトライ戦略をライブラリとして分離する方法

プロジェクト全体でリトライ戦略を再利用できるようにするためには、リトライのロジックを独立したライブラリまたはモジュールとして分離することが重要です。これにより、他のプロジェクトやコードベースでも簡単にリトライ戦略を活用でき、コードのメンテナンス性が向上します。

モジュール化のメリット

リトライ戦略をモジュールとして分離することで得られるメリットは多くあります。

再利用性の向上

複数のAPI呼び出しや異なるプロジェクトでリトライ処理を使い回せるため、DRY(Don’t Repeat Yourself)の原則に従い、コードの冗長さを防ぎます。

テストの簡略化

リトライ戦略をモジュール化することで、単体テストが容易になります。APIの各リトライ戦略の振る舞いを個別にテストし、バグの検出が早まります。

メンテナンスの効率化

一箇所でリトライ戦略を定義することで、必要に応じて戦略を変更したり修正したりする際、全体のコードベースに一貫性を持たせつつ、簡単に対応できます。

リトライ戦略をモジュール化する

次に、リトライ戦略を独立したライブラリとして分離する方法を紹介します。以下のコードは、リトライ戦略を一つのモジュールとして定義し、プロジェクト内のどこからでもインポートして使用できる形にしたものです。

// retryStrategy.ts
export async function retryWithBackoff<T>(
  apiCall: () => Promise<T>,
  retries: number,
  baseDelay: number
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }

    const delay = baseDelay * Math.pow(2, retries);
    console.log(`リトライ回数残り: ${retries}, 待機: ${delay}ms`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithBackoff(apiCall, retries - 1, baseDelay);
  }
}

このretryWithBackoff関数は、リトライのロジックを独立したモジュールに分離しており、他のAPI呼び出しでも同じリトライ戦略を簡単に使用できます。このモジュールを他のファイルからインポートして利用できます。

モジュールの使用例

次に、このリトライ戦略を別のコードベースで使用する方法を示します。モジュールをインポートし、リトライ可能なAPI呼び出しを実行します。

import { retryWithBackoff } from './retryStrategy';

async function fetchData() {
  const apiCall = () => fetch("https://example.com/api/data").then(response => {
    if (!response.ok) {
      throw new Error("APIエラー");
    }
    return response.json();
  });

  try {
    const data = await retryWithBackoff(apiCall, 3, 1000); // 3回リトライ、初回1秒待機
    console.log("データ取得成功:", data);
  } catch (error) {
    console.error("API呼び出し失敗:", error);
  }
}

fetchData();

このように、リトライ戦略をモジュール化することで、他の部分でリトライロジックを使いたい場合でも、簡単に呼び出して再利用することができます。

ライブラリとしての構成要素

リトライ戦略をライブラリとして設計する際には、次のような構成要素を考慮します。

設定可能なパラメータ

リトライ回数や間隔、エクスポネンシャルバックオフやランダム化など、柔軟に設定できるパラメータをライブラリ内に含めます。

プラグイン可能なリトライ条件

HTTPステータスコードやエラーメッセージに応じて、リトライするかどうかの条件を外部から設定できるようにします。

ロギングとデバッグサポート

リトライ時のログやエラー情報を出力する機能を追加することで、トラブルシューティングを容易にします。エラーハンドリングの際に、どのようなエラーが発生し、どのようにリトライが行われたかを記録する仕組みが役立ちます。

ユニットテストによる品質管理

リトライ戦略をライブラリ化することで、各リトライパターンをテストしやすくなります。たとえば、以下のようなテストケースを作成することで、リトライの動作を確認できます。

import { retryWithBackoff } from './retryStrategy';

test('API呼び出しが成功するまでリトライされること', async () => {
  const mockApiCall = jest.fn()
    .mockRejectedValueOnce(new Error("APIエラー"))
    .mockResolvedValueOnce("成功");

  const result = await retryWithBackoff(mockApiCall, 3, 1000);
  expect(result).toBe("成功");
  expect(mockApiCall).toHaveBeenCalledTimes(2);
});

test('最大リトライ回数を超えるとエラーが発生すること', async () => {
  const mockApiCall = jest.fn().mockRejectedValue(new Error("APIエラー"));

  await expect(retryWithBackoff(mockApiCall, 3, 1000)).rejects.toThrow("最大リトライ回数に達しました");
  expect(mockApiCall).toHaveBeenCalledTimes(4); // 初回 + 3回リトライ
});

これにより、リトライ戦略が正しく動作しているか、異なるケースに対して適切にエラーハンドリングが行われているかを確実に検証できます。

まとめ

リトライ戦略をライブラリとして分離することで、プロジェクト内での再利用性が高まり、柔軟かつ効率的なエラーハンドリングが可能になります。モジュール化したリトライ戦略は、異なるAPI呼び出しやプロジェクト間で簡単に適用でき、コードのメンテナンス性やテストの容易さも向上します。

外部ライブラリを活用したリトライ処理の最適化

リトライ戦略を一から実装することもできますが、すでに信頼性の高い外部ライブラリを利用することで、リトライ処理の開発時間を短縮し、より洗練された機能を手軽に活用できます。特に、リトライ戦略の高度なカスタマイズや複雑なリトライ条件を必要とする場合、外部ライブラリを活用することは有効です。

外部ライブラリの選択

TypeScriptで利用できるリトライ処理をサポートするライブラリはいくつかあります。代表的なものに以下が含まれます。

1. `axios-retry`

axios-retryは、人気のHTTPクライアントライブラリであるaxiosにリトライ機能を追加するライブラリです。axiosをすでに使用しているプロジェクトでは、このライブラリを使うことでシンプルにリトライ戦略を導入できます。

2. `retry`

retryライブラリは、汎用的なリトライ戦略を実装するための強力なツールで、HTTPリクエストに限らず、あらゆる非同期処理に対してリトライロジックを追加できます。エクスポネンシャルバックオフ、ジッター、カスタマイズ可能なリトライ条件など、柔軟な機能を備えています。

3. `promise-retry`

promise-retryは、Promiseベースのリトライ処理をシンプルに実装できるライブラリで、非同期関数に対してリトライ戦略を容易に追加できます。

`axios-retry`を使ったリトライ処理

axios-retryは、axiosを利用している場合に非常に有効です。API呼び出しが失敗したときに、自動的にリトライを行い、指定した回数内で成功するかどうかを判断します。

import axios from 'axios';
import axiosRetry from 'axios-retry';

// axiosインスタンスにリトライ戦略を設定
axiosRetry(axios, {
  retries: 3, // 最大リトライ回数
  retryDelay: (retryCount) => {
    return retryCount * 1000; // リトライ間隔(エクスポネンシャルバックオフ)
  },
  retryCondition: (error) => {
    // リトライする条件(サーバーエラーや503に対してのみ)
    return error.response.status >= 500 || error.response.status === 503;
  },
});

async function fetchData() {
  try {
    const response = await axios.get("https://example.com/api/data");
    console.log("データ取得成功:", response.data);
  } catch (error) {
    console.error("API呼び出し失敗:", error);
  }
}

fetchData();

このコードでは、axios-retryを使って、サーバーエラー(500系)や503エラーが発生した際に、最大3回までリトライし、リトライ間隔はエクスポネンシャルバックオフで調整しています。

`retry`ライブラリを使ったリトライ処理

retryライブラリは、より汎用的なリトライ処理を提供します。API呼び出し以外にも幅広いシナリオで利用可能です。

import { operation } from 'retry';

async function retryWithRetryLibrary(apiCall: () => Promise<any>) {
  const opts = {
    retries: 5, // 最大5回リトライ
    factor: 2,  // エクスポネンシャルバックオフの成長係数
    minTimeout: 1000, // 最初のリトライまでの待機時間(ミリ秒)
    maxTimeout: 8000, // 最大待機時間
    randomize: true,  // ジッター(ランダム化)を適用
  };

  const op = operation(opts);

  return new Promise((resolve, reject) => {
    op.attempt(async (currentAttempt) => {
      try {
        const result = await apiCall();
        resolve(result);
      } catch (error) {
        if (op.retry(error)) {
          console.log(`リトライ中...(試行回数: ${currentAttempt})`);
          return;
        }
        reject(error); // 最大リトライ回数に達した場合
      }
    });
  });
}

// 使用例
async function fetchData() {
  const apiCall = () => fetch("https://example.com/api/data").then(response => {
    if (!response.ok) {
      throw new Error("APIエラー");
    }
    return response.json();
  });

  try {
    const data = await retryWithRetryLibrary(apiCall);
    console.log("データ取得成功:", data);
  } catch (error) {
    console.error("API呼び出し失敗:", error);
  }
}

fetchData();

retryライブラリを使うことで、リトライ戦略にエクスポネンシャルバックオフやランダムな遅延を簡単に追加でき、リトライ処理が非常に柔軟にカスタマイズ可能です。

`promise-retry`を使ったシンプルなリトライ処理

promise-retryは、Promiseベースの非同期処理に対して簡単にリトライを追加するための軽量ライブラリです。

import promiseRetry from 'promise-retry';

async function retryWithPromiseRetry(apiCall: () => Promise<any>) {
  return promiseRetry((retry, number) => {
    console.log(`リトライ試行回数: ${number}`);
    return apiCall().catch(retry); // 失敗時にリトライ
  }, {
    retries: 3, // 最大3回リトライ
    minTimeout: 1000, // リトライ間隔
  });
}

// 使用例
async function fetchData() {
  const apiCall = () => fetch("https://example.com/api/data").then(response => {
    if (!response.ok) {
      throw new Error("APIエラー");
    }
    return response.json();
  });

  try {
    const data = await retryWithPromiseRetry(apiCall);
    console.log("データ取得成功:", data);
  } catch (error) {
    console.error("API呼び出し失敗:", error);
  }
}

fetchData();

promise-retryは、シンプルにリトライ処理を追加できるため、複雑な設定が不要な場合に有効です。

外部ライブラリを活用するメリット

外部ライブラリを使用することで、リトライ戦略を一から構築する手間を省き、次のようなメリットを享受できます。

効率的な開発

リトライロジックの開発時間を短縮し、実装の複雑さを大幅に軽減できます。ライブラリが持つ既存の機能を活用することで、手軽に高機能なリトライ戦略を導入できます。

拡張性と柔軟性

高度にカスタマイズ可能なリトライ戦略を提供するライブラリは、プロジェクトの規模や要件に応じて柔軟に対応できます。エクスポネンシャルバックオフ、ランダム化、条件に応じたリトライなどを容易に設定可能です。

信頼性の向上

広く使用されている外部ライブラリを活用することで、バグの少ない信頼性の高いリトライ戦略を構築できます。多くの開発者に利用され、継続的にメンテナンスされているライブラリは、将来的なプロジェクトの安定性にも寄与します。

エラーのトラブルシューティングとデバッグ

リトライ戦略を導入したとしても、すべてのAPIエラーがリトライによって解決するわけではありません。特定のエラーや障害がリトライ処理を妨げる場合、適切にトラブルシューティングを行い、エラーの根本原因を特定することが重要です。また、リトライ回数が上限に達しても成功しない場合や、リトライ自体が問題を引き起こす場合のデバッグも不可欠です。

リトライ戦略のトラブルシューティング

リトライ戦略が正しく動作しない場合や、リトライが効果を発揮していない場合に考慮すべき要素をいくつか紹介します。

1. エラーレスポンスの正確な解析

API呼び出しが失敗する場合、レスポンスのステータスコードやエラーメッセージを正確に解析することが第一歩です。400系エラー(クライアント側のエラー)は通常リトライに適していませんが、500系(サーバーエラー)は一時的な問題であることが多いため、リトライが有効です。ステータスコードやエラーメッセージを分析し、リトライ戦略が適切かどうかを判断する必要があります。

const handleApiError = (error: any) => {
  if (error.response) {
    console.log(`ステータスコード: ${error.response.status}`);
    console.log(`エラーメッセージ: ${error.response.data}`);
  } else if (error.request) {
    console.log('APIからの応答がありません');
  } else {
    console.log('リクエスト設定エラー:', error.message);
  }
};

2. リトライのログを活用する

リトライ回数や各リトライの間に発生したエラー内容をログに記録することは、トラブルシューティングの大きな助けとなります。ログを確認することで、どのタイミングでエラーが発生しているのか、リトライの条件が適切に設定されているかどうかを判断できます。

async function retryWithLogging<T>(
  apiCall: () => Promise<T>,
  retries: number,
  delay: number
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    console.error(`リトライ失敗: 残りリトライ回数: ${retries}, エラー: ${error.message}`);
    if (retries <= 0) {
      throw new Error("最大リトライ回数に達しました");
    }
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithLogging(apiCall, retries - 1, delay);
  }
}

このコードは、リトライが失敗するたびにエラーメッセージと残りリトライ回数をログに記録します。これにより、リトライがなぜ失敗しているのか、どのエラーで止まっているのかを追跡できます。

リトライが失敗した場合のデバッグ手法

リトライが上限回数に達してもエラーが解消されない場合、いくつかの要因を考慮してデバッグを進めることができます。

1. ネットワークの不安定さ

ネットワークの不安定さや帯域幅の問題でAPIの応答が返ってこない場合、ネットワークの健全性を確認する必要があります。Pingやトレースルートなどのツールを使って、APIサーバーまでのネットワーク経路が正常であるかどうかを確認します。

2. サーバー側の障害

サーバーが一時的に過負荷状態にある場合、リトライが機能しないことがあります。この場合、サーバーの状態をモニタリングし、適切なタイミングでリトライ戦略を調整します。503エラーが頻繁に返される場合、サーバーがメンテナンス中であるか、負荷がかかっている可能性があります。

3. リトライ回数や間隔の最適化

リトライ回数や間隔が適切でない場合も失敗が続く可能性があります。例えば、リトライ間隔が短すぎると、サーバーが回復する前に再試行を行い、エラーが続くことがあります。逆に、リトライ回数が多すぎると、サーバーへの負荷が増大し、システム全体のパフォーマンスが低下する恐れもあります。

タイムアウト設定の確認

API呼び出しにタイムアウト設定を追加することで、リトライする前に応答がなく、無限に待機し続ける状況を防ぎます。適切なタイムアウトを設定することで、APIサーバーが応答しない場合に迅速に次のアクション(リトライやエラー処理)に進むことができます。

const apiCallWithTimeout = async () => {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error("タイムアウトエラー"));
    }, 5000); // 5秒でタイムアウト

    fetch("https://example.com/api/data")
      .then(response => {
        clearTimeout(timeout);
        if (response.ok) {
          resolve(response.json());
        } else {
          reject(new Error("APIエラー"));
        }
      })
      .catch(error => {
        clearTimeout(timeout);
        reject(error);
      });
  });
};

このコードでは、API呼び出しが5秒以内に応答しない場合、タイムアウトエラーとして処理され、リトライ戦略に進むことができます。

リトライに失敗した場合のフォールバック戦略

すべてのリトライが失敗した場合でも、システムが完全に停止するのではなく、フォールバック戦略を用意することが重要です。たとえば、キャッシュされたデータを表示する、ユーザーにエラーメッセージを提供する、あるいは代替APIを呼び出すなどの手法があります。

キャッシュを利用する

リトライが失敗した場合、直前に取得したデータをキャッシュから取り出して表示することで、ユーザーに一定の情報を提供することができます。これにより、システムの利便性を保つことができます。

代替APIの使用

複数のAPIサービスが利用できる場合、リトライがすべて失敗したときに、別のAPIソースからデータを取得するフォールバックを実装することも可能です。APIの冗長性を持たせることで、システムの安定性が向上します。

まとめ

リトライ戦略を実装するだけでは不十分で、リトライが機能しない場合のトラブルシューティングやデバッグも不可欠です。リトライに失敗した際のログ管理やフォールバック戦略を考慮することで、システム全体の信頼性を向上させ、ユーザーに対して安定したサービスを提供できます。

TypeScriptプロジェクトでの実際の応用例

TypeScriptにおけるリトライ戦略は、外部API呼び出しや非同期処理において非常に有効です。ここでは、実際のTypeScriptプロジェクトでリトライ戦略をどのように適用できるか、いくつかの具体的な例を示します。これにより、エラーハンドリングとリトライ処理の活用方法をより深く理解できます。

応用例1: REST API呼び出しでのリトライ戦略

REST API呼び出しは、エラーやネットワークの不具合に対して非常に脆弱です。リトライ戦略を導入することで、失敗したAPIリクエストを再試行し、安定したデータ取得を可能にします。以下は、ユーザーデータを取得する際にリトライ戦略を適用した例です。

async function getUserData(userId: number): Promise<any> {
  const apiCall = () => fetch(`https://example.com/api/users/${userId}`).then(response => {
    if (!response.ok) {
      throw new Error("APIエラー: ユーザーデータの取得に失敗しました");
    }
    return response.json();
  });

  try {
    const data = await retryWithBackoff(apiCall, 3, 1000); // 3回リトライ、1秒間隔
    console.log("ユーザーデータ取得成功:", data);
    return data;
  } catch (error) {
    console.error("ユーザーデータの取得が失敗しました:", error);
    return null; // フォールバック戦略としてnullを返す
  }
}

この例では、ユーザーデータの取得時にリトライ処理を導入し、APIが一時的に失敗した場合でも再試行することで成功する可能性を高めています。最終的に3回リトライしても失敗した場合、エラーログを記録し、フォールバックとしてnullを返す設計にしています。

応用例2: マイクロサービス間の通信

複数のマイクロサービスが相互に通信するシステムでは、リクエストが失敗することが頻繁に発生します。これを防ぐため、リトライ戦略を実装して一時的な障害に対応します。以下は、支払いサービスと在庫管理サービスの間で行われる通信にリトライ処理を組み込んだ例です。

async function notifyInventoryService(orderId: string): Promise<void> {
  const apiCall = () => fetch(`https://inventory.example.com/api/orders/${orderId}/notify`, {
    method: 'POST'
  }).then(response => {
    if (!response.ok) {
      throw new Error("在庫サービスへの通知に失敗しました");
    }
  });

  try {
    await retryWithExponentialBackoff(apiCall, 5, 500); // 最大5回リトライ、エクスポネンシャルバックオフ
    console.log("在庫サービスへの通知成功");
  } catch (error) {
    console.error("在庫サービスへの通知が完全に失敗しました:", error);
    // 必要に応じて代替処理やアラートを実行
  }
}

このコードでは、支払いが完了した後に在庫管理サービスに通知を送る処理を行います。もしサービスが一時的にダウンしていたりネットワークが不安定だった場合、エクスポネンシャルバックオフで最大5回までリトライすることで、システム全体の安定性を確保します。

応用例3: サードパーティAPIのレート制限対応

サードパーティのAPIは、レート制限が設定されていることが一般的です。例えば、APIに対して一定の頻度でリクエストを送ると、Too Many Requests(429)エラーが発生する場合があります。このような場合、一定時間待機してからリトライすることで、再びアクセス可能になることを待つことができます。

async function fetchWeatherData(city: string): Promise<any> {
  const apiCall = () => fetch(`https://weatherapi.example.com/data?city=${city}`).then(response => {
    if (response.status === 429) {
      throw new Error("APIのレート制限を超えました。リトライします。");
    }
    if (!response.ok) {
      throw new Error("天気データの取得に失敗しました");
    }
    return response.json();
  });

  try {
    const data = await retryWithExponentialBackoff(apiCall, 3, 2000); // 3回リトライ、2秒間隔
    console.log("天気データ取得成功:", data);
    return data;
  } catch (error) {
    console.error("天気データの取得が失敗しました:", error);
    return null; // フォールバック戦略としてnullを返す
  }
}

この例では、サードパーティの天気APIに対するリクエストがレート制限によって拒否された場合に、2秒待機してから最大3回までリトライを行うことで、リクエストを成功させる戦略を実装しています。

応用例4: バッチ処理におけるエラーハンドリング

大規模なバッチ処理では、エラーが発生したとしても処理を中断せず、リトライ戦略を導入することで失敗したタスクを再試行することが重要です。例えば、数百件のデータをAPIに送信する処理で、一部が失敗しても処理を継続させ、後で失敗した部分だけリトライする例を示します。

async function processBatchData(dataArray: any[]): Promise<void> {
  for (const data of dataArray) {
    const apiCall = () => fetch("https://example.com/api/process", {
      method: 'POST',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' }
    }).then(response => {
      if (!response.ok) {
        throw new Error(`データ処理失敗: ${response.status}`);
      }
    });

    try {
      await retryWithBackoff(apiCall, 3, 1000); // 最大3回リトライ、1秒間隔
      console.log("データ処理成功:", data);
    } catch (error) {
      console.error("バッチデータの処理が失敗しました:", error);
      // 失敗したデータをリストに追加して、後で再処理するなどの対応を行う
    }
  }
}

このコードでは、バッチデータの処理中に一部のデータ処理が失敗した場合でもリトライを行い、それでも失敗する場合は後で再処理できるようにログを記録しています。これにより、大規模なデータ処理において効率的かつ安定したエラーハンドリングが可能になります。

まとめ

実際のTypeScriptプロジェクトでリトライ戦略を活用することで、API呼び出しの安定性やエラーハンドリングが向上します。外部APIやマイクロサービス間の通信、バッチ処理、サードパーティAPIのレート制限対応など、様々なシナリオでリトライ戦略が効果を発揮します。適切なリトライ戦略を導入することで、システム全体の信頼性を高め、よりスムーズな動作を実現できます。

まとめ

本記事では、TypeScriptにおける外部APIの呼び出し時に発生するエラーに対するリトライ戦略の重要性と、その実装方法について詳しく解説しました。固定間隔リトライやエクスポネンシャルバックオフ、API応答に基づいた動的リトライ判断、さらに外部ライブラリの活用など、様々なリトライ戦略を学びました。これらを活用することで、API呼び出しの信頼性とシステムの安定性を向上させることができ、効果的なエラーハンドリングを実現できます。

コメント

コメントする

目次