AWS-SDKでAWS APIのリクエスト数制限で悩まされた

# はじめに
どうも、Webエンジニアの大西です。

StepFunctionのMapの中でLambdaInvokeタスクを作成し、その中で`aws-sdk.EC2.runInstances()`を呼び出したところ、下記のようなエラーが返ってきました。
```txt
Error: RequestLimitExceeded: Request limit exceeded.
```

調べを進めたところ、AWSのAPIには同時リクエスト数に制限があることがわかりました。
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/APIReference/throttling.html#throttling-limits

今回は、リクエスト数制限（＝リクエストスロットリング）に関する話と回避方法について書きます。


# リクエストスロットリングとは？
詳しい話は[AWS公式の記事](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/APIReference/throttling.html#throttling-limits)に任せて、要点だけまとめると
- EC2のAPIには同時実行数の制約がある
- 制約は`runInstances`などのリソースを変更するアクションの方が厳しい
- 一番制約が厳しいものだと5個しか同じ実行できない

`runInstances`は5個で制限に引っかかるので、並列実行する時は基本気にしないとダメそうですね。。。

# 解決方法
リクエスト数制限の解決には主に2つのアプローチがあるみたいです。
- AWSに制限の引き上げを依頼する
- リトライ処理を追加する

## AWSに制限の引き上げを依頼する
制限の引き上げを引き上げればコード上で何かをしなくていいから手っ取り早いですね。
みなさん今すぐAWSに問い合わせましょう！

と言いたいところですが、そうは問屋がおろしませんでした。
（まぁ、これで解決できるなら記事書いてないですから。。。）

以下の記事を見ると、すべてのAPIで制限を引き上げられるわけではないみたいです。
https://docs.aws.amazon.com/ja_jp/general/latest/gr/aws_service_limits.html

僕が引っかかった`runInstances`も制限を引き上げられないAPIの一つでした。
https://aws.amazon.com/jp/premiumsupport/knowledge-center/ec2-launch-multiple-requestlimitexceeded/


## リトライ処理を実装する
と言うことで、唯一の現実的な解決はバックオフを実装のみだとわかりました。
じゃあ、仕方なく実装するかとなったわけですが、厄介なことにAPIによってエラーコードが異なるみたいです。
実際、開発を進める中で`registerTargets`でもリクエスト数制限に引っかかったのですが、エラーコードは`Throttling`になっていました（なんでやねん

こんなん引っかかるたびに書くなんて耐えられないわと思っていたら、リクエスト数制限で返ってくるエラーコードがまとめられているページを見つけました。
https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-configure-retries.html#cli-usage-retries-modes-standard.title

と言うわけで、上記のページに書かれたエラーコード全てに対応するリトライ処理を書きました。

```ts
const THROTTLING_ERROR_CODE_LIST = [
  "ProvisionedThroughputExceededException",
  "Throttling",
  "ThrottlingException",
  "RequestLimitExceeded",
  "RequestThrottled",
  "TooManyRequestsException",
  "RequestThrottledException",
  "TransactionInProgressException",
  "EC2ThrottledException",
];

export const getRandomInt = ({
  min,
  max,
}: {
  min: number;
  max: number;
}): number => {
  return Math.floor(Math.random() * max) + min;
};

export const delay = (ms: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, ms));


export const awsAccessRetryHandler = async <T extends Promise<any>>(
  callback: () => T,
  {
    maxRetry,
    intervalSec,
  }: {
    maxRetry: number;
    intervalSec: number;
  }
) => {
  // 最初に待ち時間を入れることで、実行タイミングが被らないようにする
  await delay((getRandomInt({ min: 0, max: 20 }) / 2) * 1000); // 0, 500, 1000, 1500, ... , 10000 [ms]

  for (let retryCounter = 0; retryCounter < maxRetry; retryCounter++) {
    try {
      return await callback();
    } catch (error) {
      if (!THROTTLING_ERROR_CODE_LIST.includes(error.code)) {
        throw new Error(error);
      }

      // RETRY回数を増やすことで実行タイミングが被っても処理される想定
      await delay(intervalSec * 1000);  // Expornatial BackoffにするならretryCounterを掛ける
      continue;
    }
  }
  throw new Error(
    `リトライ回数の上限に達しました。`
  );
};

```

使い方はこんな感じで、コールバック関数でAPIを叩くだけです。
```ts
import { EC2 } from "aws-sdk";
const ec2 = new EC2({ region: process.env.REGION });

await awsAccessRetryHandler(
    () => ec2.runInstances(options).promise(),
    {
        maxRetry: 3,
        intervalSec: 30,
    }
);
```

最初にランダムに待ち時間を置いているのは、6個以上のリクエストが被っていて、同じアルゴリズムで待ち時間を決めていた場合、リトライ先でもリクエストが被るからです。

初期の待ち時間が20パターンなのは100個ぐらいの同時実行を想定していて、`runInstances`のリクエスト数制限が5なので、20等分すれば概ね実行されるだろうという予想で作っています（`100/5=20`)。
一応100個同時実行でも動いたから大丈夫だと思っています(適当)

# おわりに
ググれば解決方法はわかりますが、ザ・初見キラーなエラーでした。
この記事で救われる人がいることを願います。