【AWS SDK】SdkClientException(例外)対策…リトライ処理を記述しよう【Java】

# 背景（はじめに）
　AWS SDK for Java 2.xのAPIを呼び出しているプログラム内で、SdkClientException（例外）が不定期に発生していました。そのプログラム内ではSdkClientExceptionを想定してなかったため、発生するとプログラムが異常終了していたようです。
　本記事に、簡単にですが説明と実施した対策を残します。

:warning: **こちらは社内に2022年8月頃に公開したメモです。**

## SdkClientExceptionって何？
[SdkClientException](https://docs.aws.amazon.com/ja_jp/sdk-for-java/latest/developer-guide/handling-exceptions.html#sdkclientexception)
> SdkClientException は、AWS にリクエストを送信しようとしたとき、または AWS からの応答を解析しようとしたときに、Java クライアントコード内で問題が発生したことを示しています。SdkClientException は、一般的に SdkServiceException よりも深刻な例外で、クライアントが AWS のサービスに対するサービス呼び出しを実行できないという重大な問題を示しています。たとえば、いずれかのクライアントでオペレーションを呼び出そうとしたときに、ネットワーク接続が利用できない場合、AWS SDK for Java は SdkClientException をスローします。

## 例外のメッセージについて
　AWSの資格情報が読み込めない旨のメッセージとなっています。
　発生直前にAWS SDKのAPIを呼び出した時は、この例外が発生しません。このタイミングで発生する（資格情報が読み込めない）原因は不明です…。

例：メッセージ冒頭部分
```
software.amazon.awssdk.core.exception.SdkClientException: Unable to load credentials from any of the providers in the chain AwsCredentialsProviderChain(credentialsProviders=[SystemPropertyCredentialsProvider(), EnvironmentVariableCredentialsProvider(), WebIdentityTokenCredentialsProvider(), ProfileCredentialsProvider(), ContainerCredentialsProvider(), InstanceProfileCredentialsProvider()]) : [SystemPropertyCredentialsProvider(): Unable to load credentials from system settings. Access key must be specified either via environment variable (AWS_ACCESS_KEY_ID) or system property (aws.accessKeyId)., EnvironmentVariableCredentialsProvider(): Unable to load credentials from system settings. Access key must be specified either via environment variable (AWS_ACCESS_KEY_ID) or system property (aws.accessKeyId)., WebIdentityTokenCredentialsProvider(): Either the environment variable AWS_WEB_IDENTITY_TOKEN_FILE or the javaproperty aws.webIdentityTokenFile must be set., ProfileCredentialsProvider(): Profile file contained no credentials for profile 'default': ProfileFile(profiles=[]), ContainerCredentialsProvider(): Cannot fetch credentials from container - neither AWS_CONTAINER_CREDENTIALS_FULL_URI or AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variables are set., InstanceProfileCredentialsProvider(): Unable to load credentials from service endpoint.]
```
AwsCredentialsProviderChain から資格情報を読み込めないようです。
```
SdkClientException: Unable to load credentials from any of the providers in the chain AwsCredentialsProviderChain
```
[Class AwsCredentialsProviderChain](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.html)
> 複数のクレデンシャルプロバイダーをチェーンするAwsCredentialsProvider実装。
> 呼び出し元が最初にこのプロバイダーに資格情報を要求すると、資格情報を提供できるようになるまで、チェーン内のすべてのプロバイダーを指定された元の順序で呼び出し、次にそれらの資格情報を返します。チェーン内のすべてのクレデンシャルプロバイダーが呼び出され、どのプロバイダーもクレデンシャルを提供できない場合、このクラスは、使用可能なクレデンシャルがないことを示す例外をスローします
InstanceProfileCredentialsProvider でサービスエンドポイントから資格情報を読み込めていない。
```
InstanceProfileCredentialsProvider(): Unable to load credentials from service endpoint.
```
[Class InstanceProfileCredentialsProvider](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.html)
> AmazonEC2インスタンスメタデータサービスからクレデンシャルをロードするクレデンシャルプロバイダーの実装。
> SdkSystemSetting.AWS_EC2_METADATA_DISABLEDがtrueに設定されている場合、EC2メタデータサービスからクレデンシャルを読み込もうとせず、nullを返します。

## 発生個所で呼び出しているAWS SDK for Java 2.xのAPI（一例）
[Ec2Client#describeInstances](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/services/ec2/Ec2Client.html#describeInstances-software.amazon.awssdk.services.ec2.model.DescribeInstancesRequest-)：EC2インスタンスを一覧表示します。
[SqsClient#getQueueAttributes](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/services/sqs/SqsClient.html#getQueueAttributes-software.amazon.awssdk.services.sqs.model.GetQueueAttributesRequest-)：指定されたキューの属性を取得します。

# 対応策：リトライ処理を実装
　資格情報を読み込めるように改善するにも読み込めない原因が不明なので、この改善は諦めます…。
　今回は、 **同じ処理をリトライすることでそれぞれの処理を完了させる** 方向で改善させることにしました。

　「[AWS SDK Java でAPIコールをExponential Backoffでリトライする](https://dev.classmethod.jp/articles/retry-api-call-with-exponential-backoff-using-aws-sdk-java/)」を参考に、下記のようなExponential Backoffを実装しました（記事ではAmazonServiceException への対策をしています）。
　[AWSが公式で公開しているリファレンスガイド](https://docs.aws.amazon.com/ja_jp/general/latest/gr/api-retries.html)に沿った記述方法でも良いのですが、下記のように **ヘルパークラスを一つ用意しておく** と、複数個所で呼び出せて、ソースの行数を余計に増やすことが無くなります。便利ですね！:relaxed:

## STEP0：「Exponential Backoff」とは何ぞや
　エクスポネンシャルバックオフ、と読みます。
　直訳すると「指数関数的後退」…指数関数的に処理のリトライ間隔を後退させるアルゴリズムのことです。
（AWSユーザーは必ず覚えておきたいアルゴリズム、のようです:bulb:）

## STEP1：ヘルパークラスを用意しよう
　1回目のAPIコールは即時に実行されます。
　1回目のAPIコールで SdkClientException が発生し、
　ステータスコードが500 or 503の場合は、2^（リトライ回数）秒のwaitを行い、リトライします。
　規定回数（ここでは5回と設定）のAPIコールに失敗した場合は、SdkClientExceptionを上位に伝搬します。
　SdkClientException以外の例外が発生した場合も、上位に伝搬します。
```java:AWSClientRequestInvoker.java
import software.amazon.awssdk.core.exception.SdkClientException;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigInteger;

/**
 * AWSClientのAPIコールを Exponential Backoff アルゴリズムでリトライするためのヘルパークラス。
 */
@Component
public class AWSClientRequestInvoker {
    private final Logger log = LoggerFactory.getLogger(AWSClientRequestInvoker.class);
    /** 最大リトライ回数 */
    static final int MAX_RETRY_COUNT = 5;

    @FunctionalInterface
    public interface AWSClientVoidRequest {
        void call();
    }

    @FunctionalInterface
    public interface AWSClientRequest<T> {
        T call();
    }

    @FunctionalInterface
    private interface Invoker<T> {
        T invoke();
    }

    public void invokeSdkClientEx(AWSClientVoidRequest request) throws SdkClientException {
        invokeSdkClientExWithRetry(() -> {
            request.call();
            return null;
        });
    }

    public <T> T invokeSdkClientEx(AWSClientRequest<T> request) {
        return invokeSdkClientExWithRetry(() -> request.call());
    }

    private <T> T invokeSdkClientExWithRetry(Invoker<T> i) {
        int retryCount = 0;
        for (;;) {
            try {
                log.info("[invokeSdkClientExWithRetry] AWSとの接続を実施します。(リトライ回数 = {}回)", retryCount);
                return i.invoke();
            } catch (SdkClientException e) {
                if (MAX_RETRY_COUNT <= retryCount) {
                    log.error("[invokeSdkClientExWithRetry] {}回目のリトライに失敗。以降はリトライ処理を行ないません。 例外: {}", retryCount, e);
                    throw e;
                } else {
                    log.warn("[invokeSdkClientExWithRetry] {}回目のリトライに失敗。例外: {}", retryCount, e);
                }
            }
            // Wait: 1, 2, 4, 8, 16, 32, 64 ...
            long waitTime = BigInteger.valueOf(2).pow(retryCount).longValue() * 1000L;
            try {
                Thread.sleep(waitTime);
            } catch (InterruptedException e) {
                // do nothing
            }
            retryCount++;
        }
    }
```
　ログ出力内容などは適宜変更してください。

（ログに「ERROR」と出力された場合に通知が飛ぶように設定しているため、
　・「接続を実施」する際はINFO、
　・「リトライ処理に失敗」したがまだリトライを行なう場合はWARN、
　・「リトライに失敗。以降はリトライ処理を行な」わない場合はERROR
　を使用しています。
　リトライ処理でも解決できなかった場合はERROR通知が発信→調査できるように、という意図です）

## STEP2：APIコール記述部分で、ヘルパークラスを呼び出そう
コメントアウト部がBeforeです。
``` java
// DescribeInstancesResponse response = ec2.describeInstances(request);
DescribeInstancesResponse response = this.awsClientRequestInvoker.invokeSdkClientEx(() -> ec2.describeInstances(request));
```
戻り値がvoidの場合は下記のようになるはず？スミマセン試してません:pray:
``` java
this.awsClientRequestInvoker.invokeSdkClientEx(() -> client.deregisterImage(req));
```

おしまい！

（記述間違っていたら適宜修正してください。そして、もしよろしければコメント等で是非ご指摘ください）

# オマケ1：動作確認しよう※本番環境以外での方法
　SdkClientExceptionは意図的に発生させることも可能なので、本番環境以外で下記コードをAWS SDKのAPIを呼び出す行の手前に追加し、動作確認を行なうのも良いかも。
※このコードが含まれたソースを本番環境にリリースしないように気を付けて！:warning:
``` java
// Math.random()で0.0～1.0の乱数生成。0.9より大きい値が生成されるとSdkClientExceptionをスローする。
if (Math.random() > 0.9) { log.error("★★ リトライ誘発 ★★"); throw SdkClientException.create("動作確認としてSdkClientExceptionを生成する");}
```

# オマケ2：Spring-Retry ではうまくいかず、今の方法に…
　一度、[Spring-Retry](https://github.com/spring-projects/spring-retry)を使用して既存の処理にリトライの仕組みを追加してみよう！と検討したことがあるのですが、ERRORが出たり、リトライ処理が行なわれなかったりして断念しました。何故うまくいかなかったのかは未だによく分かっていません…:head_bandage: