不規則にエラーを返すWebAPIを使って、マイクロサービス間のリトライを実装しよう。

![retry.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163591/58f71d82-f8ee-7bc9-f9ed-d1127e559f39.gif)

マイクロサービスアーキテクチャでは、サービス間の通信に失敗することがあります。ネットワークを介したリモートコールである以上、なんらかの異常が発生することは考慮に入れた上で設計をする必要があります。

本記事では REST API を使用した場合の API のリトライ方法について、いくつかのライブラリを使用して解説します。

## リトライする条件

サービス間の通信に失敗しても、全てリトライするというわけにはいけません。何度リトライをしても必ず失敗するエラーに対しては無駄にリトライをしないようにしましょう。
RESTful な API では、4xx 系のエラーはリトライ不要です。4xx 系エラーは主にバリデーションエラーや認証エラーなど、クライアント側に問題があるリクエストであるため何度リトライしてもエラーが返却されます。

以下に代表的な HTTP ステータスコードを挙げます。REST API の考え方やステータスコードについては、[REST API Tutorial](https://restfulapi.net/http-status-codes/)が参考になります。

| ステータスコード            | 説明                                             |
| --------------------------- | ------------------------------------------------ |
| 400 (Bad Request)           | 不正な形式のリクエスト、バリデーションエラーなど |
| 401 (Unauthorized)          | 認証エラー、認証されずにリソースにアクセスした   |
| 403 (Forbidden)             | 認可エラー、指定したリソースに対する権限がない   |
| 404 (Not Found)             | 対象のリソース、パスが見つからない               |
| 500 (Internal Server Error) | システムエラー                                   |
| 503 (Service Unavailable)   | サービスが一時的に利用できない                   |
| 504 (Gateway Timeout)       | タイムアウト、処理時間がかかりすぎている         |

一方、5xx 系のエラーを返却したサービスはリトライすることで復旧できる場合があります。レスポンスの HTTP ステータスコードを条件にして、リトライする可否を判断しましょう。

レスポンスコード以外にも、ネットワークの一時的な障害によりサービスに到達できなかった場合のエラーを考慮しましょう。ネットワーク障害の場合はリクエスト先のサービスからレスポンスコードが返却されないため、接続に失敗した旨の例外をキャッチしてリトライを実行することになります。

### 5xx Error を返す API

このようなマイクロサービスのエラーをハンドリングするコードを書くために、頻繁に障害が発生する API を作りました。 以下からアクセスしてください。

https://instability.now.sh/

リクエストを送るとランダムに 5xx 系エラーを返します。

```bash
$ curl https://instability.now.sh
{"status":504,"message":"Gateway Timeout"}
$ curl https://instability.now.sh
{"status":200,"message":"OK"}
$ curl https://instability.now.sh
{"status":200,"message":"OK"}
$ curl https://instability.now.sh
{"status":503,"message":"Service Unavailable"}
$ curl https://instability.now.sh
{"status":504,"message":"Gateway Timeout"}
```

`errorRate` をクエリパラメータに指定することで障害発生率を調整できます。

```bash
$ curl https://instability.now.sh?errorRate=99   # 99% の確率でエラー
{"status":500,"message":"Internal Server Error"}
$ curl https://instability.now.sh?errorRate=2    #  2% の確率でエラー
{"status":200,"message":"OK"}
```

POST リクエストを送信することも可能です。POST の場合はリクエストボディに `errorRate` を設定します。

```bash
$ curl -X POST -d '{ "errorRate": "20" }' https://instability.now.sh
{"status":500,"message":"Internal Server Error"}
```

詳しい API ドキュメントは[こちら](https://daisuke-awaji.github.io/instability/)を参照してください。

## リトライする方法

リトライでは以下の２つを考慮する必要があります。

- リトライの間隔
- リトライを何回繰り返すのか

**リトライの間隔**については、Exponential Backoff が良いでしょう。リトライするたびに指数関数的にその間隔を長くしていく方法です。再試行する度に、1 秒後、2 秒後、4 秒後と指数関数的に待ち時間を加えていきます。等間隔のリトライの場合、障害がおきているサービスに無駄なリクエストを発生させることになり、余計な負荷をかけてしまいます。Exponential Backoff のテクニックを使用すれば、リトライを繰り返すたびにその間隔が広がっていくのでこの問題を緩和できます。
この方法はクラウドやマイクロサービスの文脈では基本的なお作法です。AWS Solutions Architect ブログでも紹介されています。Exponential Backoff の方法にばらつき（Jitter）を加えた方法を紹介しています。

AWS Solutions Architect ブログ: [Exponential Backoff And Jitter](https://aws.typepad.com/sajp/2015/03/backoff.html)

**リトライを何回繰り返すのか**は難しい課題です。障害が発生したマイクロサービスの復旧時間に依存するところがあり、まずは 5 回などに設定しておき、運用を進めるにしたがって調整していくのが良いでしょう。

さて、今回はこの２つの考慮事項を node-fetch, request, got の各種ライブラリを使用して実装してみましょう。

### node-fetch での実装例

[node-fetch](https://github.com/node-fetch/node-fetch) の fetch メソッドは Promise を返すため比較的シンプルに実装ができます。
ネットワークエラーの場合は待ち時間なしで即座にリトライをかけ、5xx 系エラーの場合は Exponential Backoff を行います。

```ts
import fetch from "node-fetch";

export default async () => {
  const url = "https://instability.now.sh";
  const init = { method: "GET" };
  const option = { retry: { limit: 5 } };
  const result = await retryFetch(url, init, option);
  return result;
};

const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));

const retryFetch = async (url, init, option) => {
  const { retry } = option;
  for (let i = 0; i <= retry.limit; i++) {
    let res;
    try {
      res = await fetch(url, init);
    } catch (error) {
      // ネットワークエラーの場合は即座にリトライ
      console.log(error);
      continue;
    }

    if (res.status < 500) {
      // 5xx 系エラー以外の場合はレスポンスデータを返す
      return res;
    }

    // 5xx 系エラーの場合は数秒待ってからリトライ（Exponential Backoff）
    const sleepTime = 2 ** i;
    await sleep(sleepTime * 1000);
  }
};
```

### request での実装例

[request](https://github.com/request/request) の request メソッドは Promise を返さないので取り扱いやすいように、薄くラップしましょう。
あとの手続きは `node-fetch` と同様です。

```ts
import * as request from "request";

export default () => {
  const param = {
    url: "https://instability.now.sh",
    json: true
  };
  const option = { retry: { limit: 5 } };
  return retryRequest(param, option);
};

const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));

const retryRequest = async (param, option) => {
  const { retry } = option;
  for (let i = 0; i <= retry.limit; i++) {
    let res: { status: number; message: string };
    try {
      res = await requestPromise(param);
    } catch (error) {
      // ネットワークエラーの場合は即座にリトライ
      console.log(error);
      continue;
    }

    if (res.status < 500) {
      // 5xx 系エラー以外の場合はレスポンスデータを返す
      return res;
    }

    // 5xx 系エラーの場合は数秒待ってからリトライ（Exponential Backoff）
    const sleepTime = 2 ** i;
    await sleep(sleepTime * 1000);
  }
};

// Promise を返すように薄いラッパーを作る
function requestPromise(param): any {
  return new Promise((resolve, reject) => {
    request.get(param, (err, req, body) => {
      if (err) {
        reject(err);
      } else {
        resolve(body);
      }
    });
  });
}
```

### got での実装例

[got](https://github.com/sindresorhus/got) は非常に軽量でリトライの仕組みも標準的に取り揃えているシンプルな HTTP クライアントライブラリです。Promise と StreamAPI にも対応しており、現代の API クライアントライブラリとしてはかなり優秀です。`getClient` メソッドでクライアントオブジェクトを生成し、あとは `client.get(path)` の形でリクエストを送ります。

```ts
import got from "got";

export default async () => {
  try {
    const prefixUrl = "https://instability.now.sh/";
    const client = getClient(prefixUrl);
    return await client.get("").json();
  } catch (error) {
    console.log(error.response.body);
  }
};

const getClient = (url: string) => {
  const client = got.extend({
    prefixUrl: url
    retry: {
      limit: 5,
      calculateDelay: delay => {
        console.log(delay); // リトライ処理が発生した場合だけログを出力
        return 1;
      }
    }
  });
  return client;
};
```

リトライの間隔は 1 秒、2 秒、4 秒、8 秒と増えていくようで、Exponential Backoff の方法を取っているようです。got なかなか使い心地いいんじゃないでしょうか。

# さいごに

マイクロサービス間のエラーハンドリングはAPIのリトライだけを考慮すれば良いわけではありません。
リトライをする上で、各サービスは冪等な処理が行われるようにしておかなければなりませんし、必要に応じてキャッシュやサービスブローカーを導入して耐障害性をあげるテクニックもあります。
今回はその中でも初歩の初歩であるAPIのリトライについて、実装を交えながら説明しました。これで少しでも初学者の助けになりますように。