外部APIを呼び出していると、たまに503が返ってきたり接続がタイムアウトしたりしますよね。たいていは一時的なもので、少し待ってもう一度叩けば成功します。こういうときに try-catch をループで囲んでリトライを手書きするのは、地味に面倒ですし、バックオフやリトライ回数の管理でコードが汚れがちです。

そこで便利なのが spring-retry です。@Retryable というアノテーションを付けるだけで、宣言的にリトライを実現できます。この記事では @Retryable の基本から、@Recover によるフォールバック、ハマりやすい self-invocation の落とし穴、そして Resilience4j との使い分けまでを実装例で見ていきます。なお本記事は Spring Boot 3.x(spring-retry 2.x)の同期処理を前提としていて、WebFlux でのリアクティブなリトライは扱いません。

Spring Retryはいつ使うべきか

spring-retry は AOP ベースのライブラリで、メソッドにアノテーションを付けるとそのメソッドの実行を自動でリトライしてくれます。

向いているのは「もう一度試せば成功するかもしれない」失敗です。具体的には外部APIの503や502、接続タイムアウト、一時的なDBの接続エラーなどですね。逆に、入力値が不正で400が返るようなケースは何度試しても結果は変わらないので、リトライ対象にすべきではありません。リトライする例外をきちんと絞り込むのが、安全に使うコツです。

依存追加と@EnableRetryの有効化

まず依存を追加します。spring-retry 本体に加えて、AOP を動かすための spring-aspects も必要です。これを忘れるとアノテーションが効かないので注意しましょう。バージョンは Spring Boot の BOM に任せれば、Spring Boot 3.x では spring-retry 2.x が選ばれます。

dependencies {
    implementation 'org.springframework.retry:spring-retry'
    implementation 'org.springframework:spring-aspects'
}

次に @EnableRetry を有効化します。設定クラスか、@SpringBootApplication を付けたメインクラスのどちらかに付ければOKです。

@Configuration
@EnableRetry
public class RetryConfig {
}

これで準備は完了です。あとはリトライしたいメソッドにアノテーションを付けていくだけですね。

@Retryableの基本(maxAttemptsとretryFor)

外部APIを呼ぶサービスに @Retryable を付けてみます。

@Service
public class PaymentApiClient {

    @Retryable(
        retryFor = { HttpServerErrorException.class },
        maxAttempts = 3
    )
    public PaymentResult charge(ChargeRequest request) {
        return restClient.post()
            .uri("/charges")
            .body(request)
            .retrieve()
            .body(PaymentResult.class);
    }
}

ポイントは2つです。maxAttempts初回呼び出しを含めた総試行回数 なので、3 なら最初の1回+リトライ2回という意味になります。デフォルトは3です。

そして retryFor でリトライ対象の例外を指定します。RestClient は retrieve() で 5xx を受け取ると HttpServerErrorExceptionRestClientException のサブクラス)を投げるので、上のように直接指定できます。独自の ApiTemporaryException などに包んでリトライ対象を明示したい場合は、onStatus でステータスを見て変換してから投げ直すとよいでしょう。逆に特定の例外だけ除外したいときは noRetryFor を使います。

retryFor / noRetryFor は spring-retry 2.0 で追加された属性で、それ以前の include / exclude の新しい別名です。旧属性も動きますが、新規コードでは retryFor / noRetryFor を使うのがおすすめです。外部API呼び出しのコード自体については Spring BootのRestClientガイド も参考にしてみてください。

@Backoffで遅延を制御する

リトライをすぐ連打すると、相手のサーバが落ちているときに追い打ちをかけてしまいます。@Backoff で待機時間を入れましょう。

@Retryable(
    retryFor = { HttpServerErrorException.class },
    maxAttempts = 4,
    backoff = @Backoff(delay = 500, multiplier = 2.0, maxDelay = 5000, random = true)
)
public PaymentResult charge(ChargeRequest request) {
    // ...
}

delay だけを指定すると固定遅延になります。上の例のように multiplier を付けると指数バックオフになり、500ms → 1000ms → 2000ms と待機時間が伸びていきます。そのままだと際限なく伸びてしまうので、maxDelay で上限を設けておくのが現実的です。

random = true はジッターの指定です。複数のクライアントが同時に失敗したとき、リトライのタイミングが同じ瞬間に集中して相手を再び叩いてしまう(thundering herd)のを避けるために、待機時間にゆらぎを持たせます。外部API相手なら付けておくと安心です。

@Recoverで最終フォールバックを書く

すべての試行が失敗したら、最後はどうしますか。例外をそのまま投げてもいいですが、デフォルト値を返したりキャッシュにフォールバックしたいこともありますよね。そんなときは @Recover です。

@Service
public class PaymentApiClient {

    @Retryable(retryFor = { HttpServerErrorException.class }, maxAttempts = 3)
    public PaymentResult charge(ChargeRequest request) {
        // ...
    }

    @Recover
    public PaymentResult recoverCharge(HttpServerErrorException e, ChargeRequest request) {
        log.warn("リトライ上限に達したためフォールバックします", e);
        return PaymentResult.pending(request.orderId());
    }
}

@Recover メソッドにはシグネチャの一致ルールがあります。ここがズレると @Recover が呼ばれず、元の例外がそのまま外に飛んでいくので気をつけてください。

  • 戻り値の型を @Retryable メソッドと一致させる
  • 第一引数にリトライ対象の例外型を取る
  • 第一引数のあとに、元メソッドの引数を同じ順で並べられる

「フォールバックが呼ばれない」というハマりは、たいてい戻り値の型が違うか、第一引数の例外型が実際に投げられる例外と合っていないのが原因です。まずこの2点を確認しましょう。

self-invocationでリトライが効かない落とし穴

spring-retry でいちばん多いハマりが self-invocation です。@Retryable は AOP プロキシ経由で動くので、同一クラス内の別メソッドから直接呼び出すと プロキシを通らずリトライが効かない という問題が起きます。

@Service
public class OrderService {

    public void process(Order order) {
        // これはプロキシを経由しないのでリトライされない
        callExternalApi(order);
    }

    @Retryable(retryFor = HttpServerErrorException.class)
    public void callExternalApi(Order order) {
        // ...
    }
}

process から callExternalApi を呼んでいますが、これは内部呼び出しなので @Retryable がまったく効きません。アノテーションは付いているのに動かない、という状態ですね。

回避策はシンプルで、リトライ対象のメソッドを別の Bean に切り出すことです。

@Service
public class ExternalApiClient {

    @Retryable(retryFor = HttpServerErrorException.class)
    public void call(Order order) {
        // ...
    }
}

@Service
public class OrderService {

    private final ExternalApiClient apiClient;

    public OrderService(ExternalApiClient apiClient) {
        this.apiClient = apiClient;
    }

    public void process(Order order) {
        apiClient.call(order); // 別Bean経由なのでプロキシが効く
    }
}

こうすれば呼び出しがプロキシを経由するので、ちゃんとリトライが効きます。また @Retryable を付けるメソッドは public にしておく必要があります。private メソッドにはプロキシが適用されません。この「別Beanに分ける」発想は AOP 全般に共通する話で、楽観的ロックでリトライを組み合わせる JPAの楽観的ロックガイド でも同じ注意が当てはまります。

RetryTemplateでプログラム的に書く

アノテーションだと宣言的で楽ですが、「レスポンスの中身を見てリトライするか動的に決めたい」といった細かい制御をしたい場面もあります。そういうときは RetryTemplate を使えば、コードでリトライを組み立てられます。

import org.springframework.retry.RetryContext;
import org.springframework.retry.support.RetryTemplate;

RetryTemplate retryTemplate = RetryTemplate.builder()
    .maxAttempts(3)
    .exponentialBackoff(500, 2.0, 5000)
    .retryOn(HttpServerErrorException.class)
    .build();

PaymentResult result = retryTemplate.execute((RetryContext context) -> {
    // context.getRetryCount() で現在の試行回数を参照できる
    return apiClient.charge(request);
});

execute に渡すラムダは RetryCallback で、引数の contextRetryContext です。ここから試行回数などの状態を取れるので、動的な判断に使えます。とはいえ、ほとんどのケースはアノテーションで十分です。特別な事情がなければ、まず @Retryable から始めるのがおすすめです。

Spring RetryとResilience4jの使い分け

リトライといえば Resilience4j を思い浮かべる人も多いと思います。どちらを使うべきか、ざっくり整理しておきましょう。

観点Spring RetryResilience4j
主な責務リトライリトライ+Circuit Breaker+Bulkhead+RateLimiter
導入の手軽さアノテーションのみで完結設定項目が多め
向いている場面単純な自動リトライ障害の連鎖を止めたい・多機能が要る

判断はシンプルです。やりたいことが単純なリトライだけなら spring-retry で十分です。一方で、失敗が続いたら呼び出し自体を遮断する Circuit Breaker や、同時実行数を制限する Bulkhead が必要なら Resilience4j を選びましょう。詳しくは Resilience4jでサーキットブレーカーを実装するガイド を参照してください。

なお、Kafka などメッセージング基盤では独自のリトライ機構(DLT など)があるので、そちらは Kafka Producer/Consumerガイド で扱っています。

まとめ

spring-retry を使えば、依存を追加して @EnableRetry を有効化し、@Retryable を付けるだけで宣言的なリトライが実現できます。maxAttemptsretryFor で対象を絞り、@Backoff で指数バックオフをかけ、@Recover で最終フォールバックを用意する。この流れを押さえておけば、外部API呼び出しの一時的失敗にきれいに対処できます。

気をつけたいのは self-invocation で、同一クラス内の呼び出しではリトライが効かないので別Beanに切り出しましょう。そして、Circuit Breaker のような高度な耐障害性が要るなら Resilience4j、単純なリトライで足りるなら spring-retry、と責務で選び分けるのがコツです。まずは手軽な spring-retry から試してみてください。