AWS SDK for Python (boto3) のリトライ・タイムアウト制御

AWS SDK for Python (boto3) を使用してエラーが発生した場合のリトライについて、簡単に整理しました。

## リトライ・タイムアウト設定

参考) [Config Reference - botocore documentation](https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html#botocore.config.Config)

|クライアント設定|環境変数|説明|未設定時|
|:--|:--|:--|--:|
|connect_timeout||接続タイムアウト時間 (秒)|60|
|read_timeout||データ受信タイムアウト時間 (秒)|60|
|retries.mode|AWS_RETRY_MODE|リトライモード|legacy|
|retries.total_max_attempts|AWS_MAX_ATTEMPTS|最大リクエスト試行回数|5 (legacy) / 3 (standard)|
|retries.max_attempts||リトライ回数||

設定例)

```python
import boto3


client = boto3.client(
    "s3",
    config=Config(
        connect_timeout=5,
        read_timeout=5,
        retries={
            "mode": "standard",
            "total_max_attempts": 3,
        }
    )
)
```

### connect_timeout : 接続タイムアウト時間

- ネットワークの問題によりサーバーとの通信ができない場合に、エラーになるまでの時間。
- 未設定時: 60 秒

### read_timeout : データ受信タイムアウト時間

- 受信タイムアウト時間
  - リクエスト全体ではなく、データの断片を受信するたびにリセットされる。
- 未設定時: 60 秒

### retries.total_max_attempts : 最大リクエスト試行回数

- 初回を**含む**、リクエスト試行の最大回数
- 未設定時: 5 (リトライモードが legacy の場合) / 3 (リトライモードが standard の場合)
- 環境変数で指定する場合: AWS_MAX_ATTEMPTS

### retries.max_attempts : 最大リトライ回数

- 初回を**含まない**、リトライの最大回数
- retries.total_max_attempts が指定された場合は無視される。(retries.total_max_attempts が優先される)
- 環境変数で指定する場合: なし (AWS_MAX_ATTEMPTS は retries.total_max_attempts に相当する)

## リクエスト全体のタイムアウト時間

リクエスト全体のタイムアウト時間を指定することはできないと思われるため、低速環境でのアップロードには時間がかかる場合もあり、注意。

リクエスト全体で時間に上限を設けたい場合、たとえば以下のような感じで別スレッドで監視してキャンセルすると良いかもしれない。

```python
from concurrent.futures import CancelledError
from threading import Thread

import boto3
from s3transfer.futures import TransferFuture
from s3transfer.manager import TransferManager


class TransferTimeout(Thread):
    def __init__(self, upload: TransferFuture, timeout: int):
        super().__init__()
        self.upload = upload
        self.timeout = timeout
        self.count = 0

    def run(self):
        while True:
            time.sleep(1)
            if self.upload.done():
                return
            self.count += 1
            if self.count >= self.timeout:
                self.upload.cancel()
                return

    def __enter__(self):
        self.start()

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.join()


def upload(filepath: str, bucket_name: str, timeout: int):
    transfer_manager = TransferManager(boto3.client("s3"))
    upload = transfer_manager.upload(filepath, bucket_name, key)
    try:
        with TransferTimeout(upload, timeout):
            upload.result()
    except CancelledError:
        ...
```

## IP アドレスが複数存在する場合

エンドポイントに複数の IP アドレスが割り当てられていると、エンドポイントとの通信ができない場合に、リトライ回数に関わらず別の IP への接続も試みる。これは urllib3 の挙動となっており、変更できなそうだ。

参考) [Issue #2055 - urllib3](https://github.com/urllib3/urllib3/issues/2055)

例) CloudWatch Logs のエンドポイントは 8 つ IP アドレスが存在するため、接続タイムアウト時間の 8 倍の時間、待たされることになる。

```
$ dig logs.ap-northeast-1.amazonaws.com
(略)
;; ANSWER SECTION:
logs.ap-northeast-1.amazonaws.com. 20 IN A	18.181.204.225
logs.ap-northeast-1.amazonaws.com. 20 IN A	18.181.204.203
logs.ap-northeast-1.amazonaws.com. 20 IN A	18.181.204.232
logs.ap-northeast-1.amazonaws.com. 20 IN A	52.119.221.119
logs.ap-northeast-1.amazonaws.com. 20 IN A	18.181.204.204
logs.ap-northeast-1.amazonaws.com. 20 IN A	18.181.204.210
logs.ap-northeast-1.amazonaws.com. 20 IN A	54.239.96.241
logs.ap-northeast-1.amazonaws.com. 20 IN A	52.119.221.92
```