TypeScriptの非同期処理におけるエラーハンドリングとリトライ設計パターン

非同期処理は、JavaScriptやTypeScriptにおいて、バックグラウンドで実行されるタスクを効率的に管理するための重要な要素です。特に、APIリクエストやデータベースアクセス、ファイルの読み書きなど、外部リソースとの通信を行う際に頻繁に使用されます。しかし、非同期処理にはエラーが発生する可能性があり、そのエラーハンドリングが適切でない場合、プログラムの信頼性やユーザー体験が大きく損なわれることになります。

本記事では、TypeScriptにおける非同期処理でのエラーハンドリングの基本から、エラーハンドリングとリトライ戦略の設計パターンについて、具体的なコード例とともに解説していきます。

目次

非同期処理におけるエラーハンドリングの基本

非同期処理は、通常の同期処理とは異なり、プログラムの他の部分が実行される間にタスクが並行して進行します。このため、エラーが発生しても即座に検知されず、プログラムが意図せず進行する可能性があります。エラーハンドリングの適切な実装がないと、予期せぬ動作やアプリケーションのクラッシュが発生することが多いです。

TypeScriptでは、非同期処理を扱う際にPromiseとasync/awaitの2つの主要な方法が存在し、それぞれでエラーハンドリングの方法が異なります。Promiseでは.catch()を使ってエラーをキャッチし、async/awaitではtry-catchブロックを使用します。どちらの方法も適切に活用することで、非同期処理中に発生するエラーを安全に処理することが可能です。

このセクションでは、非同期処理のエラーハンドリングの基礎と、エラー発生時にどのように対処すべきかを学びます。

Promiseとasync/awaitのエラーハンドリングの違い

Promiseとasync/awaitは、TypeScriptで非同期処理を扱う際に広く使われる2つの方法ですが、エラーハンドリングの仕組みにいくつか違いがあります。ここでは、それぞれの特徴とエラーハンドリングの違いについて詳しく解説します。

Promiseのエラーハンドリング

Promiseは、非同期処理が成功した場合には.then()で結果を処理し、失敗した場合には.catch()でエラーをキャッチします。エラーは非同期的に伝播し、.catch()を使って例外を捕捉します。また、.finally()を使用することで、成功・失敗に関係なく最後に必ず実行する処理を定義できます。

function fetchData(): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("Error: Failed to fetch data");
    }, 1000);
  });
}

fetchData()
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.error(error);
  })
  .finally(() => {
    console.log("Fetch attempt finished.");
  });

async/awaitのエラーハンドリング

async/awaitは、Promiseの結果をより直感的に扱える構文で、同期処理のように非同期コードを書ける利点があります。エラーハンドリングにおいては、try-catchを使ってエラーを捕捉します。catchの中にエラーロジックを書けるため、コードが読みやすくなり、エラー処理が統一的に扱えるのが特徴です。

async function fetchData(): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("Error: Failed to fetch data");
    }, 1000);
  });
}

async function handleData() {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error(error);
  } finally {
    console.log("Fetch attempt finished.");
  }
}

handleData();

Promiseとasync/awaitの違い

  • コードの可読性:async/awaitは同期処理のように書けるため、コードがシンプルで読みやすいです。Promiseチェーンは複雑になるとネストが深くなりがちで、可読性が低下することがあります。
  • エラーハンドリングの一貫性:async/awaitはtry-catchを使うことでエラーハンドリングが統一され、複雑なロジックにも対応しやすいです。一方、Promiseは.then().catch()の形でエラーハンドリングを行うため、連続した処理があると管理が難しくなる場合があります。

このように、Promiseとasync/awaitはどちらもエラーハンドリングが可能ですが、用途やコードの複雑さに応じて使い分けることが大切です。

try-catchによる非同期エラーの管理

非同期処理におけるエラーハンドリングの中でも、try-catchは特にasync/awaitと組み合わせて用いられる強力な手法です。従来の同期処理で使われるtry-catchと同様に、非同期処理のエラーをキャッチし、適切に処理することができます。このセクションでは、try-catchによる非同期処理のエラー管理の仕組みと、その使い方について詳しく説明します。

try-catchの基本的な使い方

async/awaitを使用した非同期処理では、tryブロック内でエラーが発生した場合、自動的にcatchブロックに制御が渡され、そこでエラーを処理することができます。これにより、エラー処理をコード内で一元的に管理することが可能です。

async function fetchData(): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("Error: Data retrieval failed");
    }, 1000);
  });
}

async function handleData() {
  try {
    const data = await fetchData();  // 非同期処理の結果を待つ
    console.log("Data:", data);      // データが正常に取得された場合
  } catch (error) {
    console.error("Caught an error:", error);  // エラーが発生した場合の処理
  }
}

この例では、fetchData関数がPromiseを返し、エラーが発生した場合はrejectされます。handleData関数内で、awaitを用いてfetchDataの結果を待っていますが、エラーが発生した場合はcatchブロックに処理が移り、エラー内容がログに出力されます。

非同期処理でのエラーハンドリングの利点

try-catchを用いることで、次のような利点があります:

  • 可読性の向上then().catch()のPromiseチェーンを使ったエラーハンドリングよりも、try-catchを使ったほうが同期的なコードに近い形で書けるため、コードの可読性が向上します。
  • 一貫性のあるエラーハンドリング:非同期処理の中でも同期処理と同じようにエラーハンドリングができるため、エラーハンドリングが一貫します。これにより、複数の非同期処理を含むコードでも、エラー処理を統一的に行うことができます。

複数の非同期処理を扱う場合のtry-catch

複数の非同期処理を行う場合でも、try-catchで簡単にエラー処理を行うことができます。たとえば、複数のAPIリクエストを行い、そのどれかが失敗した場合でも、try-catchブロックで一括してエラーを処理できます。

async function getData() {
  try {
    const data1 = await fetchData1();  // API 1のデータを取得
    const data2 = await fetchData2();  // API 2のデータを取得
    console.log("Data1:", data1, "Data2:", data2);
  } catch (error) {
    console.error("An error occurred while fetching data:", error);
  }
}

この例では、2つの非同期APIリクエストを行い、いずれかが失敗した場合、catchブロックでエラーが処理されます。これにより、コード全体がクラッシュすることなく、エラーハンドリングが行われます。

非同期関数の中でのエラー再スロー

時には、エラーをキャッチして処理した後に、エラーを再度スローしたい場合もあります。この場合、catchブロック内でエラーを処理した上で、エラーを再スローすることが可能です。

async function processData() {
  try {
    const result = await fetchData();
    // 処理が成功した場合の処理
  } catch (error) {
    console.error("Error processing data:", error);
    throw new Error("Re-throwing the error after logging");  // エラーの再スロー
  }
}

再スローすることで、上位のエラーハンドリング層でさらにエラー処理を行ったり、別の処理に渡すことができます。

try-catchを活用することで、非同期処理におけるエラーハンドリングはより直感的で一貫性のあるものになり、コードの安定性を高めることが可能です。

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

非同期処理におけるエラーハンドリングは、アプリケーションの安定性や信頼性に直結する重要な部分です。適切なエラーハンドリングを実装することで、エラー発生時でもユーザー体験を損なわず、システム全体の健全性を維持できます。このセクションでは、TypeScriptにおける非同期処理のエラーハンドリングにおいて、押さえておくべきベストプラクティスを紹介します。

1. エラーを必ずキャッチする

非同期処理では、エラーをキャッチせずに無視すると、予期しない挙動やクラッシュの原因となります。Promiseであれば.catch()async/awaitであればtry-catchを使って、発生したエラーを必ずキャッチすることが重要です。エラーハンドリングを忘れると、エラーが非同期で発生しているため、デバッグが非常に困難になります。

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    return await response.json();
  } catch (error) {
    console.error("Failed to fetch data:", error);  // エラーをキャッチしてログに出力
    throw error;  // エラーを再スローして上位層での処理も可能に
  }
}

2. エラーの分類と適切な処理

エラーにはさまざまな種類があり、それぞれに適切な対応が求められます。ネットワークの障害、タイムアウト、サーバーエラー、ユーザー入力のエラーなど、エラーの種類に応じて処理を分けることがベストプラクティスです。これにより、エラー発生時の対応が明確になり、ユーザーに対するフィードバックも改善されます。

async function handleErrors() {
  try {
    const data = await fetchData();
  } catch (error) {
    if (error.message.includes('Network')) {
      console.error("Network error occurred. Retrying...");
    } else if (error.response && error.response.status === 404) {
      console.error("Resource not found (404)");
    } else {
      console.error("An unexpected error occurred:", error);
    }
  }
}

3. 必要に応じてエラーを再スローする

エラーをキャッチして処理した後、再スローすることで、上位の関数や呼び出し元でエラーハンドリングを続けることができます。これにより、エラーが適切な階層で処理され、システム全体の安定性が保たれます。

async function processData() {
  try {
    const data = await fetchData();
    // データ処理のロジック
  } catch (error) {
    console.error("Error in processData:", error);
    throw error;  // エラーを再スローして他の層で対応
  }
}

4. ロギングを活用してエラーをトラッキングする

非同期エラーは複雑なシステムでは見逃されやすいです。重要なエラーは必ずログに記録し、後からトラッキングできるようにしましょう。特に、サーバーサイドでの非同期処理では、ログによって障害発生箇所を特定し、早期対応が可能になります。外部のロギングサービス(例:SentryやDatadog)を利用して、エラーをリアルタイムに監視することも有効です。

async function fetchDataWithLogging() {
  try {
    const data = await fetchData();
    return data;
  } catch (error) {
    // ロギングサービスにエラーを送信
    loggingService.sendError(error);
    throw error;
  }
}

5. グレースフルデグラデーション(Graceful Degradation)を実装する

エラーが発生した場合でも、アプリケーション全体が崩壊するのではなく、可能な限り機能を維持することが重要です。例えば、ネットワークエラーが発生してもキャッシュされたデータを表示するなど、ユーザーへの影響を最小限に抑える工夫を行いましょう。これがグレースフルデグラデーションの考え方です。

async function fetchDataWithFallback() {
  try {
    const data = await fetch('https://api.example.com/data');
    return await data.json();
  } catch (error) {
    console.warn("Failed to fetch live data, using cached data.");
    return getCachedData();  // エラー時にはキャッシュデータを返す
  }
}

6. ユーザーに適切なフィードバックを提供する

エラーが発生した場合、単に「エラーです」と表示するのではなく、ユーザーにとって有用なフィードバックを提供することが重要です。エラーの原因や、ユーザーが取るべき行動(例えば、後で再試行する、別の方法を試すなど)を提示することで、ユーザー体験を向上させることができます。

async function fetchDataWithUserFeedback() {
  try {
    const data = await fetchData();
    return data;
  } catch (error) {
    alert("データの取得に失敗しました。インターネット接続を確認して再試行してください。");
    throw error;
  }
}

以上のベストプラクティスを参考にして、非同期処理におけるエラーハンドリングを効率的かつ効果的に行うことが、安定したアプリケーションの開発に不可欠です。

リトライパターンの概要

非同期処理においてエラーが発生する原因は、予期しないネットワーク障害やサーバーの一時的な問題など、さまざまです。これらの問題は一時的なものである可能性が高く、エラーが発生しても、再試行(リトライ)することで正常に処理が完了することがあります。リトライパターンは、こうした一時的なエラーに対応するための有効な手法です。

リトライパターンとは、処理が失敗した場合に一定回数まで再試行し、成功するか、再試行の上限に達するまで試みる手法です。このパターンは、APIリクエストや外部リソースへのアクセス時に特に有用です。リトライは、一定の間隔を置いて繰り返すこともできますし、再試行の回数や間隔を動的に変えることも可能です。

リトライパターンが有効なケース

リトライ処理が効果を発揮するケースには、以下のようなシナリオがあります。

1. 一時的なネットワークエラー

ネットワーク接続が一時的に切断された場合、すぐに再接続が回復する可能性があるため、リトライによって再度リクエストが成功することがよくあります。

2. 外部APIやサーバーの一時的な障害

外部APIやサーバーが一時的に高負荷により応答しない場合、一度目のリクエストが失敗しても、時間を置いてリトライすることで正常な応答を得られることがあります。

3. リソース競合

データベースやファイルのロックにより一時的にリソースが使用できない場合、リトライにより競合が解消され、正常に処理が実行できることがあります。

リトライ時の注意点

リトライ処理は万能ではなく、使用する際には注意が必要です。たとえば、リトライを無制限に繰り返すと、過剰な負荷をシステムや外部サービスにかけることになり、状況を悪化させることがあります。そのため、以下のポイントを考慮することが大切です。

1. リトライ回数の制限

リトライの回数は、通常は一定の上限を設けるべきです。上限を超えた場合には、処理を失敗として扱い、適切なエラーハンドリングを行います。

2. リトライ間隔の設定

連続してリトライするのではなく、リトライの間隔を設けることで、リソースの過負荷を防ぐことができます。リトライ間隔は一定でもよいですが、後述するエクスポネンシャルバックオフ(指数関数的に間隔を広げる方法)もよく利用されます。

3. 致命的エラーの判断

一時的なエラーであればリトライは有効ですが、致命的なエラー(例:無効な認証情報や権限エラーなど)に対してリトライを行うことは無意味です。そのため、エラーの種類を適切に判断して、リトライすべきエラーかどうかを見極める必要があります。

リトライパターンは、エラーが発生した際にシステムの信頼性を向上させ、ユーザー体験を損なわないための有効な手法です。次のセクションでは、リトライ処理における具体的な実装例である「エクスポネンシャルバックオフ」について説明します。

エクスポネンシャルバックオフを利用したリトライ処理

エクスポネンシャルバックオフは、リトライ処理の際に使用される有効な戦略の一つで、リトライを行うたびに待機時間を指数関数的に増加させる方法です。これにより、連続してリクエストを送信することでシステムやサーバーに過度な負荷をかけることを防ぎます。特に、APIリクエストやネットワーク接続においては、短期間に多数のリトライが行われると外部サービスの負荷が増し、問題を悪化させることがあります。

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

エクスポネンシャルバックオフは、リトライごとに待機時間を倍増させる戦略です。たとえば、最初のリトライでは1秒待機し、次のリトライでは2秒、その次は4秒というように、リトライ回数が増えるごとに待機時間も増加します。これにより、サーバーが回復するまでの余裕が与えられ、連続するリクエストによる過負荷を避けられます。

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

以下のTypeScriptの例では、エクスポネンシャルバックオフを使ったリトライ処理を実装しています。fetchWithRetry関数は指定した回数だけリトライを行い、そのたびに待機時間を指数関数的に増加させます。

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

async function fetchWithRetry(url: string, retries: number, delayTime: number): Promise<any> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (retries > 0) {
      console.log(`Retrying... attempts left: ${retries}`);
      await delay(delayTime);
      return fetchWithRetry(url, retries - 1, delayTime * 2);  // エクスポネンシャルバックオフ:待機時間を倍増
    } else {
      throw new Error(`Failed after ${retries} retries: ${error.message}`);
    }
  }
}

// 使用例
fetchWithRetry("https://api.example.com/data", 5, 1000)
  .then(data => console.log("Data:", data))
  .catch(error => console.error("Error:", error));

コードの解説

  • delay関数: 指定されたミリ秒間、処理を遅延させるために使用します。リトライ時に一定時間待機するために活用します。
  • fetchWithRetry関数: 非同期でAPIリクエストを実行し、エラーが発生した場合にはリトライを試みます。retriesパラメータでリトライの回数を指定し、delayTimeで最初の待機時間を指定します。リトライが行われるごとに、待機時間が倍増します。
  • エクスポネンシャルバックオフ: リトライごとにdelayTimeを倍にし、待機時間を延長しています。これにより、失敗後の負荷を分散させる効果が期待できます。

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

エクスポネンシャルバックオフには以下のような利点があります。

1. サーバーへの負荷軽減

失敗後のリクエストをすぐに再送しないため、サーバーが回復するまでの時間を確保できます。これにより、短時間に大量のリクエストが殺到することを防ぎ、システム全体の安定性を保つことができます。

2. ユーザー体験の向上

ユーザーがリクエストに失敗しても、時間をかけて再試行を行うため、アプリケーションが回復しやすく、エラーが短期的な問題であればリトライにより正常な結果を返すことができます。

3. 効果的なリソース管理

リトライが短期間に集中することを避けるため、クライアント側でもリソース(特にAPIの使用回数など)を効率的に使用できます。特に制限のあるAPIに対して有効です。

エクスポネンシャルバックオフのカスタマイズ

待機時間の増加に対しても、カスタマイズが可能です。たとえば、最大の待機時間を設定したり、ランダム性を加えることで、異なるクライアントからのリトライが同時に集中するのを避けることができます。これを「ジッタ」と呼び、ネットワーク負荷を分散させるために使用されます。

async function fetchWithJitterRetry(url: string, retries: number, delayTime: number): Promise<any> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (retries > 0) {
      const jitter = Math.random() * 500;  // ジッタを加える
      await delay(delayTime + jitter);  // 待機時間にジッタを加える
      return fetchWithJitterRetry(url, retries - 1, delayTime * 2);
    } else {
      throw new Error(`Failed after ${retries} retries: ${error.message}`);
    }
  }
}

このように、エクスポネンシャルバックオフはリトライ処理の中でも効果的な方法であり、システムのパフォーマンスを保ちながらエラーハンドリングを強化することができます。次に、カスタムリトライロジックをTypeScriptでどのように設計できるかを見ていきます。

カスタムリトライロジックの設計

エクスポネンシャルバックオフをはじめとする標準的なリトライパターン以外にも、アプリケーションの要件やエラーハンドリングのニーズに応じて、独自のカスタムリトライロジックを設計することが可能です。カスタムリトライロジックを実装することで、エラーの種類や条件に応じた柔軟なリトライ戦略を構築でき、より効果的なエラーハンドリングが実現できます。

このセクションでは、TypeScriptを使用して、リトライ回数の条件、リトライ間隔のカスタマイズ、特定のエラーに基づく処理など、さまざまなカスタムリトライロジックを設計する方法を紹介します。

エラーの種類に応じたリトライ条件

すべてのエラーがリトライに適しているわけではありません。たとえば、認証エラーや権限エラーはリトライしても解決することはなく、むしろ致命的なエラーとして扱うべきです。そのため、エラーの種類に基づいてリトライするかどうかを判断するロジックを設けることが重要です。

async function fetchWithCustomRetry(url: string, retries: number): Promise<any> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (retries > 0) {
      if (error.message.includes('Network') || error.message.includes('503')) {
        console.log(`Network issue detected, retrying... attempts left: ${retries}`);
        await delay(1000);  // 固定の待機時間を設定
        return fetchWithCustomRetry(url, retries - 1);
      } else {
        console.error("Non-retryable error:", error);
        throw error;  // リトライしないエラーは再スロー
      }
    } else {
      throw new Error(`Failed after multiple retries: ${error.message}`);
    }
  }
}

この実装のポイント

  • エラーメッセージの確認: error.messageを解析し、特定のエラーに対してのみリトライを行うロジックを追加しています。たとえば、ネットワークエラーや503エラー(サービス一時不可)などは一時的なものであり、リトライに適しています。
  • リトライ対象外エラー: 認証エラーや他の致命的なエラーの場合は、すぐに処理を中断し、エラーを再スローします。

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

単純にリトライを繰り返すだけでなく、リトライの間隔を状況に応じてカスタマイズすることも可能です。例えば、APIの応答時間に応じてリトライ間隔を調整したり、サーバーの負荷を考慮してランダムな間隔でリトライするなどの工夫が考えられます。

async function fetchWithDynamicRetry(url: string, retries: number, delayTime: number): Promise<any> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (retries > 0) {
      // ここで動的にリトライ間隔を調整
      const adjustedDelay = Math.min(5000, delayTime * Math.random() + 1000);  // 最大5秒までのランダム遅延
      console.log(`Retrying in ${adjustedDelay} ms... attempts left: ${retries}`);
      await delay(adjustedDelay);
      return fetchWithDynamicRetry(url, retries - 1, delayTime * 2);  // 遅延を増加させながらリトライ
    } else {
      throw new Error(`Failed after ${retries} retries: ${error.message}`);
    }
  }
}

この実装のポイント

  • 動的な遅延の適用: リトライごとに遅延時間を動的に調整し、ランダム性を持たせることで、外部APIやサーバーにかかる負荷を分散しています。また、delayTimeを倍にすることで、リトライするたびに待機時間を増やし、無意味な連続リトライを防ぎます。

特定の条件下でリトライ処理を止める

リトライ処理中に特定の条件が満たされた場合、リトライを停止し、処理を終了させることもできます。たとえば、エラーログを監視し、リトライの効果が期待できないと判断した場合には、リトライを途中で中断することが適切です。

async function fetchWithConditionalRetry(url: string, retries: number): Promise<any> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    // 一定条件でリトライを中断する例
    if (error.message.includes('500')) {
      console.error("Server error detected, aborting retries.");
      throw error;  // 500エラーの場合、すぐにリトライを中断
    }

    if (retries > 0) {
      console.log(`Retrying... attempts left: ${retries}`);
      await delay(1000);
      return fetchWithConditionalRetry(url, retries - 1);
    } else {
      throw new Error(`Failed after ${retries} retries: ${error.message}`);
    }
  }
}

この実装のポイント

  • リトライ停止条件: HTTP 500エラーのようなサーバー側の内部エラーはリトライしても意味がないため、このケースでは即座にリトライを停止し、エラーを投げ返します。

カスタムリトライの利点

  • 柔軟なエラーハンドリング: エラーの種類や状況に応じて、リトライの実行を柔軟に制御できるため、より適切なエラーハンドリングが可能です。
  • リソースの効率的な利用: リトライ間隔や回数を調整することで、無駄なリクエストを防ぎ、サーバーやネットワークリソースを効率的に利用できます。
  • システム全体の安定性向上: 無駄なリトライやリソース負荷を軽減することで、システム全体の安定性が向上します。

カスタムリトライロジックを実装することで、アプリケーションの要件に応じた最適なエラーハンドリングとリトライ処理が可能になります。次のセクションでは、サードパーティライブラリを活用したリトライ処理の実装について説明します。

サードパーティライブラリを用いたリトライ処理

TypeScriptで非同期処理のリトライを実装する際には、手動でリトライロジックを構築するだけでなく、便利なサードパーティライブラリを活用することができます。これらのライブラリは、リトライ処理を簡単に設定でき、カスタマイズ性も高いため、複雑なリトライパターンを効率よく実装するのに役立ちます。特に、API呼び出しやHTTPリクエストを行う際に広く使われるaxiosと、リトライ処理に特化したretryライブラリを使ったリトライ処理の実装方法について解説します。

axiosを使ったリトライ処理

axiosは、HTTPリクエストを簡単に送信できる人気のあるライブラリです。このaxiosには、リトライ処理をサポートするプラグインとしてaxios-retryというライブラリが存在します。このライブラリを使うと、HTTPリクエストに対するリトライ処理を簡単に追加できます。

まず、axiosaxios-retryをインストールします。

npm install axios axios-retry

次に、axios-retryを使ったリトライ処理の設定例を見てみましょう。

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

// axiosにリトライ機能を追加
axiosRetry(axios, {
  retries: 3,  // リトライ回数
  retryDelay: (retryCount) => {
    return retryCount * 1000;  // リトライごとに1秒ずつ遅延
  },
  retryCondition: (error) => {
    // リトライ対象はネットワークエラーまたは5xxエラーのみ
    return error.response?.status >= 500 || error.code === 'ECONNABORTED';
  }
});

// リトライ付きのHTTPリクエストを送信
async function fetchData() {
  try {
    const response = await axios.get('https://api.example.com/data');
    console.log(response.data);
  } catch (error) {
    console.error('Failed to fetch data after retries:', error);
  }
}

fetchData();

この実装のポイント

  • retries: リトライの最大回数を指定します。この例では、最大3回リトライします。
  • retryDelay: リトライ間隔をカスタマイズするオプションで、リトライごとに1秒ずつ遅延させています。このように、リトライのタイミングを動的に制御できます。
  • retryCondition: 特定のエラーに対してのみリトライを行う条件を指定します。この例では、ネットワークエラーや500番台のサーバーエラーに対してのみリトライを行う設定にしています。

このように、axios-retryを使うことで、複雑なリトライ処理を簡単に追加でき、さらにリトライ条件や遅延時間のカスタマイズも容易に行えます。

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

リトライ処理に特化したライブラリとして、retryがあります。このライブラリは、任意の関数に対してリトライ処理を追加することができ、幅広いユースケースに対応可能です。特に、API呼び出しやデータベース接続のリトライなど、さまざまな非同期処理で活用できます。

まず、retryをインストールします。

npm install retry

次に、retryを使ったリトライ処理の設定例を見てみましょう。

import * as retry from 'retry';

async function fetchData(): Promise<any> {
  const operation = retry.operation({
    retries: 5,  // リトライ回数
    factor: 2,  // 遅延時間を指数関数的に増加
    minTimeout: 1000,  // 最低遅延時間1秒
    maxTimeout: 4000,  // 最大遅延時間4秒
    randomize: true  // 遅延時間にランダム性を追加
  });

  return new Promise((resolve, reject) => {
    operation.attempt(async (currentAttempt) => {
      try {
        console.log(`Attempt ${currentAttempt}`);
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        resolve(data);  // 成功時にリゾルブ
      } catch (error) {
        if (operation.retry(error)) {
          console.log(`Retrying... Attempt ${currentAttempt}`);
          return;  // エラーがリトライ可能なら再試行
        }
        reject(operation.mainError());  // リトライ上限に達した場合はリジェクト
      }
    });
  });
}

fetchData()
  .then((data) => console.log('Data:', data))
  .catch((error) => console.error('Failed to fetch data after retries:', error));

この実装のポイント

  • retries: リトライ回数を5回に設定しています。指定された回数まで失敗した場合、最終的にエラーをスローします。
  • factor: リトライ間隔が指数関数的に増加するように設定します。最初のリトライでは1秒待機し、次に2秒、4秒と増加していきます。
  • minTimeoutmaxTimeout: リトライ間隔の最小値と最大値を指定し、無限に待たないように制限します。
  • randomize: 遅延時間にランダム性を追加することで、サーバーに一斉にリクエストが送信されるのを防ぎます。

このretryライブラリを使えば、さまざまなリトライ戦略を簡単に実装できます。また、非同期処理以外にも、同期的な処理に対してもリトライロジックを適用できるため、幅広い用途に対応可能です。

サードパーティライブラリを使う利点

  • 迅速な実装: リトライロジックを手動で構築する必要がなく、ライブラリの設定だけで簡単にリトライ処理を追加できます。
  • 柔軟なカスタマイズ: リトライ回数、遅延時間、エラーハンドリング条件などを細かくカスタマイズでき、アプリケーションの要件に合わせたリトライ戦略を容易に構築できます。
  • テスト済みの信頼性: これらのライブラリは広く利用されており、リトライ処理の信頼性が高いです。自前でロジックを作成するよりも、バグの少ない堅牢な実装が期待できます。

サードパーティライブラリを活用することで、複雑なリトライ処理を簡潔に実装し、非同期処理におけるエラーハンドリングの効率を大幅に向上させることができます。次のセクションでは、実際のユースケースに基づいたリトライ処理の応用例について紹介します。

具体的なユースケース

リトライ処理は、さまざまな場面で有効に活用されますが、特にAPI呼び出しやデータベース接続、外部サービスとの連携といった一時的なエラーが発生しやすい状況で効果を発揮します。このセクションでは、実際のユースケースに基づいて、リトライ処理の具体例をいくつか紹介します。

1. API呼び出しにおけるリトライ処理

API呼び出しは、インターネット接続の問題やサーバー側の一時的なエラーなど、外的な要因で失敗することがよくあります。リトライ処理を導入することで、これらの一時的な障害を回避し、安定したデータ取得が可能になります。以下は、リトライ処理を適用したAPI呼び出しの例です。

async function fetchDataWithRetry(url: string, retries: number): Promise<any> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (retries > 0) {
      console.log(`Retrying... attempts left: ${retries}`);
      await delay(1000);  // 1秒待って再試行
      return fetchDataWithRetry(url, retries - 1);  // 再試行
    } else {
      throw new Error(`Failed after ${retries} retries: ${error.message}`);
    }
  }
}

fetchDataWithRetry("https://api.example.com/data", 3)
  .then(data => console.log("Fetched data:", data))
  .catch(error => console.error("Failed to fetch data:", error));

この例では、APIが一時的に応答しない場合でも、3回まで再試行を行い、データを取得するようにしています。こうしたリトライ処理により、ユーザーに対するエラーメッセージの表示頻度を減らし、アプリケーションの信頼性を向上させることができます。

2. データベース接続のリトライ

データベースに接続する際に、一時的な接続エラーやタイムアウトが発生することがあります。特に、クラウドベースのデータベースやリモートサーバーへのアクセスでは、ネットワークの不安定さが影響することがあります。リトライを導入することで、接続エラー時に適切な再試行を行い、アプリケーションの処理を中断することなく続行することが可能です。

async function connectToDatabaseWithRetry(retries: number): Promise<void> {
  try {
    await database.connect();
    console.log("Successfully connected to the database");
  } catch (error) {
    if (retries > 0) {
      console.log(`Retrying database connection... attempts left: ${retries}`);
      await delay(2000);  // 2秒待機
      return connectToDatabaseWithRetry(retries - 1);
    } else {
      console.error("Failed to connect to the database after multiple attempts");
      throw error;
    }
  }
}

connectToDatabaseWithRetry(3)
  .then(() => console.log("Database operations can proceed"))
  .catch(error => console.error("Database connection failed:", error));

この例では、データベース接続に3回の再試行を行い、成功するまで待機します。再試行が成功しなければ、致命的なエラーとして処理されます。

3. 外部サービスとの連携におけるリトライ処理

外部のサードパーティサービスと連携している場合、そのサービスが一時的にダウンすることや、レスポンスが遅くなることがあります。例えば、支払い処理サービスやメッセージングAPIなど、依存している外部システムに障害が発生することは珍しくありません。リトライ処理を実装することで、障害が回復した後に処理を正常に進めることが可能です。

async function processPaymentWithRetry(paymentInfo: PaymentInfo, retries: number): Promise<void> {
  try {
    const response = await paymentGateway.processPayment(paymentInfo);
    if (!response.success) {
      throw new Error("Payment failed");
    }
    console.log("Payment processed successfully");
  } catch (error) {
    if (retries > 0) {
      console.log(`Retrying payment... attempts left: ${retries}`);
      await delay(3000);  // 3秒待機
      return processPaymentWithRetry(paymentInfo, retries - 1);
    } else {
      console.error("Payment processing failed after multiple attempts");
      throw error;
    }
  }
}

processPaymentWithRetry({ amount: 100, currency: "USD" }, 3)
  .then(() => console.log("Payment completed"))
  .catch(error => console.error("Failed to process payment:", error));

この例では、外部の支払いゲートウェイに対して3回までリトライを行い、支払い処理が成功するか、全てのリトライが失敗するまで待機します。

4. ユーザー入力の検証とリトライ

ユーザーが入力したデータが不正確な場合や、サーバーでバリデーションエラーが発生する場合にも、リトライ処理を導入することでユーザーが再入力するチャンスを提供できます。特にフォーム送信などでは、エラーメッセージを表示した後に一定時間待機して、再送信を試みることが有効です。

async function submitFormWithRetry(formData: FormData, retries: number): Promise<void> {
  try {
    const response = await api.submitForm(formData);
    if (!response.success) {
      throw new Error("Form submission failed");
    }
    console.log("Form submitted successfully");
  } catch (error) {
    if (retries > 0) {
      console.log(`Retrying form submission... attempts left: ${retries}`);
      await delay(2000);  // 2秒待機
      return submitFormWithRetry(formData, retries - 1);
    } else {
      console.error("Form submission failed after multiple attempts");
      throw error;
    }
  }
}

submitFormWithRetry({ name: "John Doe", email: "john@example.com" }, 3)
  .then(() => console.log("Form submitted"))
  .catch(error => console.error("Failed to submit form:", error));

この例では、ユーザーがフォームを送信した際に失敗した場合、3回まで自動的に再送信を試みることで、ユーザー体験を向上させます。

ユースケースから学ぶリトライ処理の重要性

これらのユースケースからわかるように、リトライ処理は一時的なエラーやネットワークの不安定さに対処し、アプリケーションの信頼性を向上させる上で非常に重要です。特に、API呼び出しやデータベース接続、外部サービスの利用時にリトライ処理を適切に実装することで、エラーによる影響を最小限に抑え、ユーザー体験の向上につながります。

次のセクションでは、リトライ処理を実装する際の注意点について説明します。

リトライ処理の落とし穴と注意点

リトライ処理は、システムの信頼性を向上させるための重要な手法ですが、正しく設計しなければ、かえって問題を引き起こす可能性があります。ここでは、リトライ処理を導入する際に注意すべき落とし穴と対策について解説します。

1. リトライによる過負荷のリスク

リトライ処理は、システムやサーバーに負荷をかける可能性があります。特に、短い間隔で頻繁にリトライを行うと、サーバーが過負荷状態になり、パフォーマンスが低下することがあります。これを防ぐためには、以下の対策が重要です。

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

リトライのたびに待機時間を徐々に増加させるエクスポネンシャルバックオフを使用することで、過度なリトライを防ぎ、サーバーにかかる負荷を軽減できます。

最大リトライ回数の設定

リトライ回数に上限を設けることで、無限にリトライし続けることを防ぎます。たとえば、3回から5回程度を上限とし、リトライが失敗した場合は適切なエラーメッセージを返すようにします。

2. 再試行が無意味なエラーへのリトライ

リトライ処理は一時的なエラーに対して効果的ですが、すべてのエラーがリトライに適しているわけではありません。例えば、認証エラーやリクエストの構文エラーなど、根本的な問題に起因するエラーは、リトライしても解決しません。

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

リトライするかどうかをエラーの種類によって判断することが重要です。例えば、ネットワークエラーや一時的なサーバー障害に対してはリトライを行い、認証エラーや権限エラーに対してはリトライを行わないようにするのがベストです。

3. ユーザーへの適切なフィードバック不足

リトライ処理が失敗した場合や、再試行を繰り返しても問題が解決しない場合、ユーザーに適切なフィードバックを提供しないと、ユーザー体験が大きく損なわれます。

ユーザーに分かりやすいエラーメッセージを表示する

リトライの失敗後、エラーメッセージには具体的な情報を含めるべきです。例えば、「インターネット接続を確認してください」や「後でもう一度お試しください」といった具体的な行動指示を含めることで、ユーザーは適切な対処を取ることができます。

4. リトライによる処理の遅延

リトライ処理を実行すると、特に複数回の再試行が発生した場合、全体の処理時間が長くなることがあります。これにより、ユーザーが結果を待つ時間が長くなり、アプリケーションのパフォーマンスが低下します。

最大遅延時間の設定

リトライによって発生する遅延時間を制限することが重要です。例えば、リトライの待機時間に上限を設定し、一定以上の遅延が発生しないようにすることで、処理が無限に遅れることを防ぎます。

5. リトライ中の状態管理

リトライ中にシステムの状態が変わることもあります。例えば、ユーザーが操作をキャンセルしたり、タイムアウトが発生した場合、リトライを続けることは不要になります。このような状態変化に対応できないと、無駄なリトライを続けたり、アプリケーションの状態が不整合を起こす可能性があります。

キャンセルやタイムアウトをサポートする

リトライ処理を実装する際には、リトライのキャンセルやタイムアウトを考慮することが重要です。ユーザーが操作をキャンセルした場合や、一定時間が経過した場合にはリトライを停止し、適切にエラーハンドリングを行います。

リトライ処理の設計におけるまとめ

リトライ処理はアプリケーションの信頼性を向上させる強力な手法ですが、設計を誤ると過負荷や無意味なリトライ、ユーザー体験の悪化につながる可能性があります。これらの落とし穴を回避するためには、エクスポネンシャルバックオフやリトライ回数の制限、適切なエラーメッセージの提供など、慎重な設計が必要です。

次のセクションでは、今回のリトライ処理に関するポイントを簡潔にまとめます。

まとめ

本記事では、TypeScriptにおける非同期処理のエラーハンドリングとリトライ戦略について詳しく解説しました。Promiseとasync/awaitを用いた基本的なエラーハンドリングの方法から、リトライパターン、エクスポネンシャルバックオフ、カスタムリトライロジックの設計、さらにサードパーティライブラリの活用方法まで、幅広くカバーしました。

リトライ処理は一時的なエラーに対して有効ですが、過負荷や無意味なリトライを防ぐため、慎重に設計する必要があります。正しくリトライ処理を導入することで、アプリケーションの信頼性とユーザー体験を大幅に向上させることが可能です。

コメント

コメントする

目次