見出し画像

一時的な障害に強いシステム(アプリケーション)を構築する

はじめに

こんにちは、SHIFTの開発部門に所属している Katayama です。

どうしても一時的なネットワークの障害などでシステム(アプリケーション)がエラーになってしまうことがある。この時、REST APIの呼び出しやDBへのクエリーなどをリトライをすることでアプリケーションを利用するエンドユーザーを煩わせることなく、処理を継続できる場合がある。つまり、リトライ処理を行うことで(完全ではないものの)一時的な障害に強いアプリケーションの構築ができる。

今回は、一時的な障害に強いアプリケーションの構築のために部分的に効果のあるリトライ処理の実装を、REST API の呼び出し時にエラーが発生したらリトライするを例に、やってみたいと思う。

※おまけに、AWS SDK for JavaScript v3を用いた DynamoDB(データベース)へのクエリがエラーになったとき、リトライ処理はどうなっているか?についても触れている。

REST API の呼び出し時にエラーになったらリトライする

今回はフロントエンドから REST API をaxiosで呼び出すという場面でのリトライ処理の実装をやってみる。

結論

// src/plugins/custom-axios.js
import axios from "axios";
import axiosRetry from "axios-retry";
// 省略

export default () => {
  const client = axios.create({ baseURL: "/api/v1" });

  axiosRetry(client, { retryDelay: axiosRetry.exponentialDelay });
  // 省略

  return client;
};

上記のように axios のインスタンスを作成し、そのインスタンスに対してaxios-retryというライブラリを使って、条件に合致する場合に指定回数 HTTP リクエストをリトライするように設定するだけ。この axios-retry は、その実装を見ればわかるが、axios のInterceptorsという、リクエスト・レスポンスが処理される前に自分でカスタムの処理を行える機能を利用して、リトライ処理を行う設定をしている(その意味ではライブラリを利用せずとも、自前で実装することも可能だが、ライブラリを利用する習慣に倣い今回はライブラリを利用した)。

このように実装をすると、例えば、GET リクエストでエラーが発生すると、以下のように 3 回リクエストがリトライされることを確認できる。

※上記の「省略」の部分については、REST API のレスポンスを sanke_case -> camelCase に変換する、リクエストを camelCase -> snake_case に変換するという処理の実装をしているが、それについては「おまけ」の「axios の Interceptors を使ったケース変換」を参照(JavaScript 内では camelCase を使いたいが、REST API の定義が snake_case である、というような場合に、ケースの違いを暗黙的に解決したいという場面を想定している)。

axios-retry のオプションについて

retries

リトライをする回数を指定するためのオプション。デフォルトでは 3 回に設定されている。

retryDelay: axiosRetry.exponentialDelay

Optionsに書かれているとおり、リクエストをリトライするまでの遅延させる時間(ミリ秒)を設定するためのオプションで(以下、公式からの引用)。

A callback to further control the delay in milliseconds between retried requests. By default there is no delay between retries. Another option is exponentialDelay (Exponential Backoff). The function is passed retryCount and error. (リクエストを再試行する際の遅延をミリ秒単位で制御するためのコールバックです。デフォルトでは、再試行間の遅延はありません。もうひとつのオプションは exponentialDelay (Exponential Backoff) です。この関数には retryCount と error が渡されます。)

今回はライブラリ側で用意しているexponentialDelayを利用しているが、これは指数バックオフというリトライの戦略の 1 つで、リトライする間隔を指数関数的に伸ばしていくというもの(指数バックオフ(Exponential Backoff)については様々なサイトで開設されているのでそのアルゴリズムについては今回は割愛する)。

実際に動画ではリトライの 1 回目、2 回目、3 回目が実行されるまでの間隔が等間隔ではなかったことができると思う。

retryCondition

リトライする条件を設定する事ができるオプションで、今回は特に設定していなかったが、自分でカスタムのリトライ条件を設定する事もできる。デフォルトの設定は公式に書いてある通り、isNetworkOrIdempotentRequestError というもので、実装としてはここに書かれている。

例えば、ネットワーク系のエラーかつ、リトライ可能なエラーの場合には、全ての HTTP メソッドでリトライをするという条件にしたい場合には以下のような実装になるだろう。

import isRetryAllowed from "is-retry-allowed";

// 省略
axiosRetry(client, {
  retryDelay: axiosRetry.exponentialDelay,
  retryCondition: (error) =>
    !error.response &&
    Boolean(error.code) && // Prevents retrying cancelled requests
    isRetryAllowed(error) &&
    error.code !== "ECONNABORTED" &&
    error.response.status >= 500 &&
    error.response.status <= 599,
});
// 省略

まとめとして

今回は一時的な障害に強いシステムを構築するために、リトライ処理を組み込む方法を見てきた。API を呼び出す実装をする際や DB へのクエリ実行時には、リトライ処理を入れるのは必須になると思うので、今後開発をしていく際には常にリトライ処理を組み込むことを意識しておきたいと思った(DB へのクエリ実行時のリトライについては、「おまけ」で見てような形で、リトライ処理が DB クライアントに実装されているか?を確認するというのを忘れないようにする事が重要だろう)。

おまけ

axios の Interceptors を使ったケース変換

実装としては以下のようになる。

// 省略
import snakecaseKeys from "snakecase-keys";
import camelcaseKeys from "camelcase-keys";

export default () => {
  const client = axios.create({ baseURL: "/api/v1" });

  // 省略
  client.interceptors.request.use((request) => {
    const { data } = request;
    if (!data) return request;

    return {
      ...request,
      data:
        typeof data === "string" ? data : snakecaseKeys(data, { deep: true }),
    };
  });

  client.interceptors.response.use((response) => {
    const { data } = response;
    if (!data) return response;

    return {
      ...response,
      data: camelcaseKeys(data, { deep: true }),
    };
  });

  return client;
};

やっている事はシンプルで、(REST API を想定しているので)リクエスト・レスポンスの JSON のキーのケースをそれぞれsnakecase-keyscamelcase-keysを利用して変換している。

1 点、リクエストの部分だけ typeof data === 'string'で data が string か?で分岐しているが、こちらについて axios の動きについて簡単に触れつつ、なぜこの分岐があるかについて補足をする。

まず、axios はデフォルトで transformRequest という関数が設定できるオプションを持つ。この関数はサーバーにデータを送信する前にそのリクエストデータを変更できるようにするためにあり(以下、公式からの引用)、デフォルトではここの実装にあるような処理が行われ、最終的に「a string or an instance of Buffer, ArrayBuffer, FormData or Stream」がリクエストとして送信されるデータになる(デフォルトの設定はコードいうとここで読み込まれている)。

transformRequest allows changes to the request data before it is sent to the server(サーバーに送信する前にリクエストデータを変更できるようにします。) The last function in the array must return a string or an instance of Buffer, ArrayBuffer, FormData or Stream(配列の最後の関数は、文字列か Buffer、ArrayBuffer、FormData、Stream のインスタンスを返す必要があります。)

つまり、axios を普段利用する際に、data を Object(JSON)で記述してリクエストが送信できているのは、デフォルトの設定でその JSON を string に変換するという事が行われているからである(公式の data の部分の説明(以下)からもそれは分かるだろう)。

上記で見てきた通り、transformRequest を自分で設定していない場合には、デフォルトの設定で data が JSON であれば string になるという事が分かった。ここから、axios-retry での動きと関連付けて、typeof data === 'string'の分岐の意味を見ていきたいと思う。

axios-retry の実装を見ると、response の interceptors を定義してリトライを実現しており、interceptors の引数の config(Request Config)を使って再度 axios でリクエストを実行している(実装としては以下の部分)。

return new Promise((resolve) =>
  setTimeout(() => resolve(axios(config)), delay)
);

という事は、上記で見てきたように、1 度目のリクエスト時に data の中身は string に変換されているので、リトライ時のリクエストの config の data も string になる。つまり、リクエストの interceptors に渡ってくる data は string になり、data が string の場合には前回と全く同じリクエストで問題ない(既に 1 回目のリクエスト時にケース変換済み)なので、そのまま data を設定するように実装している(axios-retry のここの実装の通り、transformRequest の実装が data をそのまま data として return する実装になっているので、リトライ時のリクエストの interceptors に渡ってくる data が string になる)。

DynamoDB へのクエリを実行する際の retry 処理について

AWS SDK for JavaScript v3の DynamoDB のクライアントでは、リトライ処理はどうなっているか?をリファレンス・コードを見ていく事で理解を深めてみる。

DynamoDB のクライアントの API リファレンスの config を確認してみると、retryModeretryStrategyという設定項目がある事が分かる。

retryMode:Specifies which retry algorithm to use.(どの再試行アルゴリズムを使用するかを指定する。)
retryStrategy:The strategy to retry the request. Using built-in exponential backoff strategy by default.(リクエストを再試行するためのストラテジー。デフォルトでは組み込みの指数関数的バックオフ戦略を使用します。)

上記の説明から、特に何も設定しなけば exponential backoff(指数バックオフ)のリトライ戦略(どのようにリトライ処理を実行するか)でリトライが行われることが分かるので、AWS SDK の DynamoDB のクライアントを利用している限りは、特に何もせずともリトライ処理がデフォルトで実装されているので、リトライ処理に関しては気にしなくてもよいだろう。

せっかくなので実際にコードの方ではどうなっているか?も一応確認してみると、まず、DynamoDB のコンストラクタresolveRetryConfigでリトライに関する設定がされている事が分かる。retryStrategyの実装の通り、もし config に retryStrategy の設定があればそのでリトライ戦略で上書きされ、config に設定がなければ今度は retryMode でリトライ戦略が決まる事が分かる。この retryMode は何も設定しなけば、__getRuntimeConfig実装部分を見て分かる通り、(環境変数等にも設定がなければ)config.ts の STANDARDの値(standard)が設定される。つまり、retryMode をデフォルトにする= retryStrategy を StandardRetryStrategy(指数バックオフ)にするという設定になる。

※ちなみに、enum に設定されている adaptive モードについては Node.js ではないが、Boto3(Python)のリファレンスAdaptive retry modeに記載がある。

・参考:Error retries and exponential backoff
・参考:Exponential Backoff And Jitter

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/