AWS SDK Java でAPIコールをExponential Backoffでリトライする

2016.01.04

渡辺です。 弊社は今日から仕事始めなので、ブログも今日からはじめたいと思います。

AWS SDKなどを利用してスクリプトやプログラムからAWSのAPIコールを行うとき、稀にネットワークエラーやリクエスト上限に遭遇します。 これはAPIコールである以上、想定される範囲の結果です。 システムエラーとはせずに、正常なレスポンスが返るまで、何度かリトライを行うべきでしょう。

しかしながら、実際にスクリプトやプログラムでリトライを実装すると冗長なコードとなります。 また、効率良く待機時間を設定する必要もあります。 2016年最初のエントリーは、小ネタとして、AWS SDK JavaでAPIコールをリトライするヘルパークラスを紹介します。

AWSでのエラーリトライとExponential Backoff

AWSのAPIコールのリトライでは、Exponential Backoff(エクスポネンシャルバックオフ)を利用することが推奨されています(AWS でのエラーの再試行とエクスポネンシャルバックオフ)。 この方法は、リトライまでの待機時間を累積的に長くしていくことがポイントです。

APIコールはリトライで成功する可能性が高い性質があります。 Exponential Backoffを実装し、最初のリトライまでは1秒、次のリトライまでは2秒、その次のリトライまでは4秒といった形でリトライまでの待機時間を長くします。 一時的なネットワークの問題であれば、すぐに成功が返ると思います。 一時的な障害で、リカバリに数十秒かかるような場合であれば、10秒や30秒後のリトライが効果的なリトライとなるわけです。

Javaでリトライを実装する

Javaのコードでのリトライは、概ね次のようなコードとなります。

DescribeInstancesResult res;
int retryCount = 0;
for (; ; ) {
  try {
    res = client.describeInstances(req);
    break;
  } catch (AmazonServiceException e) {
    if (MAX_RETRY_COUNT <= retryCount) throw e;
    if (e.getStatusCode() != 500 && e.getStatusCode() != 503) throw e;
  }
  try {
    Thread.sleep(1000L);
  } catch (InterruptedException e) {
    // do nothing
  }
  retryCount++;
}

しかし、これを全てのAPIコールに適用するとなると、ウンザリします...。

実行したいコードは1行です。 しかし、エラーハンドリングからリトライの処理まで、しかも各所に書いてしまったら可読性は最悪です。 リファクタリングしましょう、Lambda式を使って♪

Lambda式の活用

というわけで、簡単にExponential Backoffでリトライを行うためのヘルパークラスを作成してみました。 利用方法としては、Lambda式を使い、次のようにAPIコールを行います。 Exponential Backoffでのリトライは、後述のヘルパークラスに隠蔽しました。

// 戻り値がvoidでない場合、
DescribeInstancesResult res = AWSClientRequestInvoker.invoke(() -> client.describeInstances(req));
// 戻り値がvoidの場合、
AWSClientRequestInvoker.invoke(() -> client.deregisterImage(req));

これならば、コードの可読性低下は最小限で済みますね。

ヘルパークラスのコードはこんな感じです。 適当に改変してご利用ください。

import com.amazonaws.AmazonServiceException;

/**
 * AWSClientのAPIコールを Exponential Backoff アルゴリズムでリトライするためのヘルパークラス。
 *
 * 1回目のAPIコールは即時に実行される。
 * 1回目のAPIコールで AmazonServiceException が発生し、
 * ステータスコードが500または503の場合は、2^(リトライ回数)秒のwaitを行い、リトライする。
 * 規定回数(7回)のAPIコールに失敗した場合は、AmazonServiceExceptionを上位に伝搬する。
 * AmazonServiceException以外の例外が発生した場合も、上位に伝搬する。
 *
 * Lambda式を用いて以下のように利用する。
 *
 * 戻り値がvoidでない場合、
 * DescribeInstancesResult res = AWSClientRequestInvoker.invoke(() -> client.describeInstances(req));
 *
 * 戻り値がvoidの場合、
 * AWSClientRequestInvoker.invoke(() -> client.deregisterImage(req));
 *
 * Created by shuji on 2016/01/04.
 */
public class AWSClientRequestInvoker {

    static final int MAX_RETRY_COUNT = 7; // about 2 minutes

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

    public static void invoke(AWSClientVoidRequest request) throws AmazonServiceException {
        invokeWithRetry(() -> {
            request.call();
            return null;
        });
    }

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

    public static <T> T invoke(AWSClientRequest<T> request) {
        return invokeWithRetry(() -> request.call());
    }

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

    private static <T> T invokeWithRetry(Invoker<T> i) {
        int retryCount = 0;
        for (; ; ) {
            try {
                return i.invoke();
            } catch (AmazonServiceException e) {
                if (MAX_RETRY_COUNT <= retryCount) throw e;
                if (e.getStatusCode() != 500 && e.getStatusCode() != 503) throw e;
            }
            // If 500 or 503 error, do retry API call.
            // 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++;
            // retry in next LOOP
        }
    }

}

それでは、本年もよろしくお願いします。