🤖

Axios で良い感じにリトライ機能を実装する

2023/01/10に公開

こんにちは。ぬこすけです。

axios は JavaScript 界隈では有名で http 通信をするのに便利なライブラリですが、タイムアウトで通信に失敗した時にリトライする機能はありません。

リトライ機能を追加するためのライブラリ、例えば axios-retry もありますが、簡易的であればライブラリを導入せずともサクッとリトライ機能は実装できるので紹介したいと思います。

実現する機能

この記事で紹介する機能は次のようになります。

  • リクエストしてタイムアウトになった時にリトライをする。
    • 指定した回数、成功するまでリクエストする。
    • リトライする時は指定した時間、間を空けてリクエストする。
    • リクエストに成功、または指定した回数分リトライを実行したら、リトライ回数はリセットする。
  • URL ごとでリトライ情報を保持する。
    • 例えば、 /hoge/fuga にリクエストした時に、 /hoge でタイムアウトエラーになった時には /fuga のリトライ回数には影響を与えない。

コード例

説明に入る前に先にコード例を載せておきます。 axios のバージョンは 1.2.2 です。
(一部処理を簡略化しています。 最後に CodeSandbox で完成形を載せています)

  • axios にリトライ機能を追加する関数( retryAxios
import { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";

interface RetrySetting {
  readonly maxRetryCount: number;
  readonly retryDelay?: number;
}

const retryAxios = (
  axiosInstance: AxiosInstance,
  { maxRetryCount, retryDelay = 0 }: RetrySetting
): void => {
  const retryCounterMap = new Map();
  axiosInstance.interceptors.response.use(
    (response) => {
      retryCounterMap.delete(response.config.url);
      return response;
    },
    async (error) => {
      if (error.code === "ECONNABORTED") {
        const { url } = error.config;
        const retryCounter = (retryCounterMap.get(url) || 0) + 1;
        if (retryCounter > maxRetryCount) {
          retryCounterMap.delete(url);
          return Promise.reject(error);
        }
        retryCounterMap.set(url, retryCounter);
        return new Promise((resolve, reject) =>
          setTimeout(async () => {
            try {
              const res = await axiosInstance.request(
                error.config as AxiosRequestConfig
              );
              resolve(res);
            } catch (e) {
              reject(e);
            }
          }, retryDelay)
        );
      }
      return Promise.reject(error);
    }
  );
};

export default retryAxios;
  • retryAxios を使う側のコード
import axios from "axios";
import retryAxios from "./retryAxios";

const axiosInstance = axios.create({
  baseURL: "https://hogeeeeeeeeeeeeeeeeee.com",
  timeout: 1000
});

retryAxios(axiosInstance, { maxRetryCount: 3, retryDelay: 3000 });

axiosInstance1.get("/fuga");
axiosInstance1.get("/piyo");

いくつかポイントを分けて説明します。

リトライ情報を保持する Map を追加

const retryCounterMap = new Map();

retryAxios 内で最初に Map を生成しています。

この Map はキーにリクエストした URL 、バリューにリトライした回数を記憶します。
retryCounterMap があることで URL ごとでリトライを管理できるわけです。

余談ですが、キーとバリューの管理なら Object でもできます。

const retryCounterMap = {};

ですが、頻繁に読み書きを行うなら Map の方がパフォーマンス的にベターです。

https://qiita.com/nuko-suke/items/50ba4e35289e98d95753#キーバリューを頻繁に追加や削除する場合はmapを使う

レスポンスをカスタマイズしてリトライを実装

  axiosInstance.interceptors.response.use(
    (response) => {
      retryCounterMap.delete(response.config.url);
      return response;
    },
    async (error) => {
      if (error.code === "ECONNABORTED") {
        const { url } = error.config;
        const retryCounter = (retryCounterMap.get(url) || 0) + 1;
        if (retryCounter > maxRetryCount) {
          retryCounterMap.delete(url);
          return Promise.reject(error);
        }
        retryCounterMap.set(url, retryCounter);
        return new Promise((resolve, reject) =>
          setTimeout(async () => {
            try {
              const res = await axiosInstance.request(
                error.config as AxiosRequestConfig
              );
              resolve(res);
            } catch (e) {
              reject(e);
            }
          }, retryDelay)
        );
      }
      return Promise.reject(error);
    }
  );

axios.interceptors.response.use を使うことでレスポンスをフックしてカスタマイズすることができます。

https://axios-http.com/docs/interceptors

第一引数に正常にレスポンスを受け取った時の処理、第二引数にエラーになった時の処理を指定します。

まず第二引数にエラーになった時の処理を見てみましょう。

    async (error) => {
      if (error.code === "ECONNABORTED") {
        const { url } = error.config;
        const retryCounter = (retryCounterMap.get(url) || 0) + 1;
        if (retryCounter > maxRetryCount) {
          retryCounterMap.delete(url);
          return Promise.reject(error);
        }
        retryCounterMap.set(url, retryCounter);
        return new Promise((resolve, reject) =>
          setTimeout(async () => {
            try {
              const res = await axiosInstance.request(
                error.config as AxiosRequestConfig
              );
              resolve(res);
            } catch (e) {
              reject(e);
            }
          }, retryDelay)
        );
      }
      return Promise.reject(error);
    }

エラーコードが ECONNABORTED (タイムアウト)になった時にリトライを実行します。
簡単に次のような処理になります。

  1. url をキーに Map ( retryCounterMap )に保持しているリトライ回数を引き当てて 1 加算。
  2. 指定した Max のリトライ回数を超えたら、リトライ情報をリセット。
  3. 指定した Max のリトライ回数内だったら、指定した時間分遅延して再度リクエスト

次に正常にレスポンスを受け取った時の処理を見てみましょう。

    (response) => {
      retryCounterMap.delete(response.config.url);
      return response;
    },

正常にレスポンスを受け取れた場合は、Map に保持しているリトライ回数をリセットします。
こうすることで再度同じ URL へのリクエストでタイムアウトが起きた時にもリトライ処理を走らせることができます。

retryAxios を使う側はリトライ回数の上限などを指定

const axiosInstance = axios.create({
  baseURL: "https://hogeeeeeeeeeeeeeeeeee.com",
  timeout: 1000
});

retryAxios(axiosInstance1, { maxRetryCount: 3, retryDelay: 3000 });

axiosInstance.get("/fuga1");
axiosInstance.get("/piyo1");

最初に axios.create をすることで、 baseURL など axios に設定を加えた上でインスタンス化します。

このインスタンスに対して、 retryAxios でリトライ回数の上限やリトライする際の遅延時間を指定してリトライ機能を拡張します。

CodeSandbox の完成形

完成形のコードを貼っておきます。

CodeSandbox の console を見ると、リクエストする URL ごとにリトライをしていることがわかります。

※リトライはタイムアウトではなく単純にエラーになった時に実行するように変更はしています。
※特に try~catch でエラーハンドリングはしていないので画面はエラーが表示されます。

最後に

ライブラリは使わず、 axios を拡張してリクエストする URL ごとでリトライする実装を紹介しました。

なお、今回のコードを考える上で次の記事が参考になりました!

https://zenn.dev/longbridge/articles/f933f0d6023c16

この記事を元に、

  • グローバルではなくリクエストする URL ごとでリトライを管理する。
    • 例えば、 /hoge/fuga にリクエストした時に、 /hoge でタイムアウトエラーになった時には /fuga のリトライ回数には影響を与えないようにする。
  • axios の API インターフェースを壊さずに機能拡張する。
  • リトライに遅延時間を追加する。
  • リクエストに成功した時などしかるべきタイミングでリトライ情報をリセットする。

など改良を加えさせていただきました。

ぜひ皆さんの参考になればと思います。良い JavaScript ライフを!by ぬこすけ

Discussion