Spring Framework 7の@Retryable入門:外部API障害に強いSpring Bootアプリを作る

Spring BootでWebアプリケーションを開発していると、外部APIを呼び出すケースは珍しくありません。

たとえば、

Spring Boot
    ↓
決済API
配送API
認証API
AWSなどの外部サービス

といった構成です。

しかし、外部APIは常に成功するとは限りません。

503 Service Unavailable

504 Gateway Timeout

一時的なネットワーク障害

Connection Timeout

など、一時的な理由で失敗することがあります。

このような障害に対して、

1回失敗しただけで処理全体をエラーにする

のではなく、

少し待ってからもう一度実行する

という方法が有効な場合があります。

この仕組みがRetry(リトライ)です。

Spring Framework 7では、Spring Framework本体にRetry機能が追加され、@Retryableを使って簡単にリトライを実装できるようになりました。

この記事では、

Spring Boot
    ↓
RestClient
    ↓
外部API
    ↓
503
    ↓
@Retryable
    ↓
再試行

という構成を実際に作りながら、Spring Framework 7の@Retryableについて解説します。

Spring Framework 7の@Retryableとは

Spring Framework 7では、Resilience機能として次のような仕組みがSpring Framework本体に追加されています。

@Retryable
@ConcurrencyLimit
RetryTemplate

@Retryableをメソッドに付けることで、そのメソッドが例外をスローした場合に自動的に再実行できます。

たとえば、

@Retryable
public void callExternalApi() {
    // 外部API呼び出し
}

とするだけで、失敗時に再試行できます。

Spring Framework 7のデフォルト設定では、

初回実行
↓
失敗
↓
1秒待機
↓
Retry 1
↓
1秒待機
↓
Retry 2
↓
1秒待機
↓
Retry 3

となります。

重要なのは、

maxRetries = 3

が、

合計3回実行

ではないことです。

正しくは、

初回実行 1回
+
Retry 3回

= 最大4回実行

です。

Spring Framework 7の@Retryableでは、maxRetriesは「初回実行後に何回再試行するか」を表します。

従来のSpring Retryとの違い

Springを長く使っている方は、

org.springframework.retry.annotation.Retryable

を見たことがあるかもしれません。

従来はSpring Retryという別プロジェクトを利用するのが一般的でした。

Spring Framework 7では、新しくSpring Framework本体にRetry機能が入りました。

今回使用するのはこちらです。

import org.springframework.resilience.annotation.Retryable;

つまり、今回の記事では、

org.springframework.retry.annotation.Retryable

ではなく、

org.springframework.resilience.annotation.Retryable

を使用します。

名前がほぼ同じなので、importを間違えないように注意しましょう。

今回作るアプリケーション

今回は商品情報を外部APIから取得するアプリケーションを想定します。

Client
   │
   │ GET /products/100
   ▼
Spring Boot
   │
   ▼
ProductService
   │
   ▼
ProductApiClient
   │
   │ HTTP
   ▼
External Product API

外部APIが一時的に、

503 Service Unavailable

を返した場合、

1回目 → 503

少し待つ

2回目 → 503

少し待つ

3回目 → 200 OK

となれば、Spring Boot側は正常なレスポンスを返せます。

開発環境

この記事では次の環境を前提とします。

Java 21
Spring Boot 4.1.x
Spring Framework 7.0.x
Gradle
RestClient

Spring Boot 4.1系であれば、Spring Framework 7系が利用されます。

build.gradle

まず依存関係を設定します。

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.1'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

ここで注目したいのが、

implementation 'org.springframework.retry:spring-retry'

を追加していないことです。

今回使用するRetry機能はSpring Framework 7本体の機能なので、従来のSpring Retryライブラリを追加する必要はありません。

@Retryableを有効化する

@Retryableを付けただけでは、Retry機能は有効になりません。

Spring Framework 7では、

@EnableResilientMethods

を設定します。

package com.example.retrydemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.resilience.annotation.EnableResilientMethods;

@EnableResilientMethods
@SpringBootApplication
public class RetryDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(
                RetryDemoApplication.class,
                args
        );
    }
}

@EnableResilientMethodsを設定すると、Springが@RetryableなどのResilienceアノテーションを処理するようになります。

Spring公式ドキュメントでも、Resilienceアノテーションを有効化する方法として@EnableResilientMethodsが案内されています。

RestClientを設定する

次に外部APIを呼び出すRestClientを作ります。

package com.example.retrydemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

@Configuration
public class RestClientConfig {

    @Bean
    RestClient productRestClient(
            RestClient.Builder builder
    ) {
        return builder
                .baseUrl("https://api.example.com")
                .build();
    }
}

Spring FrameworkのRestClientは同期型のHTTPクライアントです。

まずは普通に外部APIを呼び出してみる

ProductApiClientを作ります。

package com.example.retrydemo.product;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class ProductApiClient {

    private final RestClient restClient;

    public ProductApiClient(RestClient productRestClient) {
        this.restClient = productRestClient;
    }

    public ProductResponse getProduct(String productId) {

        return restClient
                .get()
                .uri("/products/{id}", productId)
                .retrieve()
                .body(ProductResponse.class);
    }
}

レスポンスはrecordで定義します。

package com.example.retrydemo.product;

public record ProductResponse(
        String id,
        String name,
        int price
) {
}

この状態では、

GET /products/100
        ↓
External API
        ↓
503 Service Unavailable
        ↓
Exception

となります。

RestClientはデフォルトで4xxや5xxのレスポンスを例外として扱います。

@Retryableを付ける

ここで@Retryableを追加します。

package com.example.retrydemo.product;

import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class ProductApiClient {

    private final RestClient restClient;

    public ProductApiClient(RestClient productRestClient) {
        this.restClient = productRestClient;
    }

    @Retryable
    public ProductResponse getProduct(String productId) {

        return restClient
                .get()
                .uri("/products/{id}", productId)
                .retrieve()
                .body(ProductResponse.class);
    }
}

これだけで、例外が発生するとRetryされます。

ただし、この実装には問題があります。

デフォルトでは基本的にどの例外でもRetry対象になります。

そのため、

400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found

までRetryしてしまう可能性があります。

これは望ましくありません。

4xxと5xxでは意味が違う

外部APIのエラーは、大きく次のように考えられます。

4xx
↓
リクエスト側に問題がある可能性が高い

5xx
↓
外部サービス側の一時障害の可能性がある

例えば、

404 Not Found

が返っているのに、

1秒待つ
↓
もう一度404

1秒待つ
↓
もう一度404

としても、基本的には意味がありません。

一方、

503 Service Unavailable

なら、

負荷増大
一時的なメンテナンス
サーバー障害

などによって一時的に失敗している可能性があります。

そのため実務では、

何でもRetryする

のではなく、

Retryして意味のある障害だけをRetryする

ことが重要です。

Retry対象の例外を作る

外部APIの一時障害を表す例外を作ります。

package com.example.retrydemo.product;

public class RetryableExternalApiException
        extends RuntimeException {

    public RetryableExternalApiException(
            String message
    ) {
        super(message);
    }
}

一方、Retryしないエラーも定義します。

package com.example.retrydemo.product;

public class ExternalApiClientException
        extends RuntimeException {

    public ExternalApiClientException(
            String message
    ) {
        super(message);
    }
}

RestClientで4xxと5xxを分ける

RestClientを次のように変更します。

package com.example.retrydemo.product;

import org.springframework.http.HttpStatusCode;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class ProductApiClient {

    private final RestClient restClient;

    public ProductApiClient(RestClient productRestClient) {
        this.restClient = productRestClient;
    }

    @Retryable(
            includes = RetryableExternalApiException.class,
            maxRetries = 3,
            delay = 500
    )
    public ProductResponse getProduct(String productId) {

        return restClient
                .get()
                .uri("/products/{id}", productId)
                .retrieve()

                .onStatus(
                        HttpStatusCode::is5xxServerError,
                        (request, response) -> {
                            throw new RetryableExternalApiException(
                                    "External API server error: "
                                            + response.getStatusCode()
                            );
                        }
                )

                .onStatus(
                        HttpStatusCode::is4xxClientError,
                        (request, response) -> {
                            throw new ExternalApiClientException(
                                    "External API client error: "
                                            + response.getStatusCode()
                            );
                        }
                )

                .body(ProductResponse.class);
    }
}

ポイントは、

includes = RetryableExternalApiException.class

です。

これによって、

RetryableExternalApiException
        ↓
Retryする

一方、

ExternalApiClientException
        ↓
Retryしない

という制御ができます。

Spring Framework 7の@Retryableでは、includesとexcludesを利用してRetry対象の例外を絞り込めます。また、対象例外はネストしたcauseまで評価されます。

実際の処理の流れ

例えば外部APIが、

1回目 → 503
2回目 → 503
3回目 → 200

と返したとします。

Spring Boot側では次のように動きます。

ProductApiClient.getProduct()
        │
        ▼
External API
        │
       503
        │
        ▼
RetryableExternalApiException
        │
        ▼
500ms待機
        │
        ▼
Retry #1
        │
       503
        │
        ▼
500ms待機
        │
        ▼
Retry #2
        │
       200
        │
        ▼
ProductResponse

呼び出し元から見ると、

ProductResponse product =
        productApiClient.getProduct("100");

という普通のメソッド呼び出しです。

Retry処理はSpringのProxyによって外側から適用されます。

指数バックオフを使う

外部サービスが障害を起こしているとき、

100msごと
100msごと
100msごと

のように短い間隔で大量のRetryをすると、むしろ相手の負荷を増やしてしまう可能性があります。

そこでよく使われるのが、

Exponential Backoff(指数バックオフ)

です。

例えば、

500ms
↓
1000ms
↓
2000ms
↓
4000ms

のように、Retryするたびに待機時間を長くします。

Spring Framework 7ではmultiplierを利用できます。

@Retryable(
        includes = RetryableExternalApiException.class,
        maxRetries = 3,
        delay = 500,
        multiplier = 2,
        maxDelay = 3000
)
public ProductResponse getProduct(String productId) {
    // ...
}

この場合、

初回実行
↓
失敗

500ms

Retry #1
↓
失敗

1000ms

Retry #2
↓
失敗

2000ms

Retry #3

というように待機時間が伸びていきます。

maxDelayを設定することで、待機時間が際限なく増えることも防げます。

Jitterも設定する

多数のアプリケーションが同時に外部API障害を検知した場合を考えてみましょう。

全インスタンスが、

500ms後にRetry

すると、

障害発生

↓ 500ms

App1 ─┐
App2 ─┼─→ 外部API
App3 ─┤
App4 ─┘

とRetryが一斉に集中する可能性があります。

そこで利用できるのが、

Jitter

です。

待機時間にランダム性を持たせます。

@Retryable(
        includes = RetryableExternalApiException.class,
        maxRetries = 3,
        delay = 500,
        jitter = 100,
        multiplier = 2,
        maxDelay = 3000
)

Spring Framework 7の@Retryableでは、delay、jitter、multiplier、maxDelayを組み合わせてバックオフを設定できます。

実運用では、固定間隔でRetryするだけでなく、指数バックオフやJitterを検討するとよいでしょう。

application.ymlでRetry設定を管理する

Retry回数をソースコードへ直接書きたくないこともあります。

例えば、

maxRetries = 3

を、

application.yml

から変更できるようにしたいケースです。

Spring Framework 7の@RetryableにはString版の設定があります。

@Retryable(
        includes = RetryableExternalApiException.class,
        maxRetriesString = "${external-api.retry.max-retries}",
        delayString = "${external-api.retry.delay}",
        multiplierString = "${external-api.retry.multiplier}",
        maxDelayString = "${external-api.retry.max-delay}"
)
public ProductResponse getProduct(String productId) {
    // ...
}

application.ymlは次のようにします。

external-api:
  retry:
    max-retries: 3
    delay: 500ms
    multiplier: 2
    max-delay: 3s

これなら環境ごとに設定を変更できます。

local
staging
production

でRetry設定を変えたい場合にも便利です。

maxRetriesStringやdelayStringなどはプロパティプレースホルダーやSpELをサポートしています。

Retry全体のタイムアウトも考える

Retry回数だけ設定すると、

外部API自体のタイムアウト
+
待機時間
+
Retry

によって、処理時間が予想以上に長くなることがあります。

Spring Framework 7.0.2以降では、@RetryableにRetry処理全体のtimeoutも設定できます。

例えば、

@Retryable(
        includes = RetryableExternalApiException.class,
        maxRetries = 5,
        delay = 500,
        multiplier = 2,
        timeout = 5000
)

とすれば、Retry処理全体に上限を設定できます。

ただし、ここで注意したいのは、

RetryのtimeoutとHTTP通信自体のtimeoutは別物

ということです。

実務では、

Connection Timeout
Read Timeout
Retry Timeout

をセットで設計することが重要です。

POSTのRetryには注意する

ここは非常に重要です。

例えば次のような決済APIを考えます。

POST /payments

Spring Bootから決済APIを呼び出します。

Spring Boot
    ↓
POST /payments
    ↓
決済サービス

このとき、

決済サービスでは処理成功

しかし

レスポンスが返る前に通信切断

が起きたとします。

Spring Boot側から見ると、

失敗した

ように見えます。

そこでRetryすると、

POST /payments
        ↓
決済成功

通信エラー

Retry
        ↓
POST /payments
        ↓
もう一度決済

となる可能性があります。

つまり、

二重決済です。

GETとPOSTではRetryの危険度が違う

一般的には、

GET
↓
同じリクエストを複数回送っても
状態が変わりにくい

ため、Retryしやすいです。

一方、

POST
↓
データ作成
決済
注文登録

などは状態を変更するため、単純なRetryは危険です。

例えば、

@Retryable
public PaymentResponse createPayment() {
    // POST /payments
}

と何も考えずに実装するのは避けましょう。

POSTをRetryするなら冪等性を考える

POSTを安全にRetryしたい場合に重要になるのが、

Idempotency(冪等性)です。

例えばリクエストに、

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

を付けます。

1回目

POST /payments
Idempotency-Key: abc123

↓

決済成功

レスポンスが受け取れずRetryしても、

2回目

POST /payments
Idempotency-Key: abc123

↓

すでに処理済み
↓
同じ結果を返す

とできれば二重実行を防げます。

そのため、

Retryできるか?

を考えるときは、

その処理は何度実行しても安全か?

まで考える必要があります。

RetryすべきHTTPステータス

実務では、例えば次のように考えます。

HTTPステータスRetry理由
400 Bad Request基本しないリクエスト内容の問題
401 Unauthorized基本しない認証情報の問題
403 Forbidden基本しない権限の問題
404 Not Found基本しないリソースが存在しない
408 Request Timeoutケースによる一時障害の可能性
429 Too Many Requests検討Retry-Afterなどを考慮
500 Internal Server Error検討一時障害の可能性
502 Bad GatewayRetry候補upstream障害の可能性
503 Service UnavailableRetry候補一時停止・過負荷の可能性
504 Gateway TimeoutRetry候補upstream timeoutの可能性

ただし、この表を機械的に使うのではなく、利用している外部APIの仕様を確認することが重要です。

特に429 Too Many Requestsでは、レスポンスのRetry-Afterを提供するAPIもあります。

@RetryableはProxyで動く

@Retryableを使ううえで、もう1つ重要なポイントがあります。

Retry処理はSpringのProxyを利用します。

イメージとしては、

Caller
   ↓
Spring Proxy
   ↓
@Retryable
   ↓
ProductApiClient

です。

そのため、同じクラスの中からRetry対象メソッドを呼び出す、

self invocationには注意が必要です。

例えば、

@Component
public class ProductApiClient {

    public ProductResponse getProduct(String id) {

        return callExternalApi(id);
    }

    @Retryable
    public ProductResponse callExternalApi(String id) {
        // 外部API
    }
}

このように、

this.callExternalApi()

相当の呼び出しになると、Spring Proxyを経由しません。

ProductApiClient
    ↓
同じProductApiClient

だからです。

Spring公式ドキュメントでも、@RetryableはProxy経由で呼び出されるメソッドへ適用される仕組みとして説明されています。

Retry対象の処理は別Beanへ分け、

ProductService
      ↓
ProductApiClient
      ↓
@Retryable

という形にすると分かりやすいでしょう。

Controllerから呼び出す

最後にControllerを作ります。

package com.example.retrydemo.product;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductApiClient productApiClient;

    public ProductController(
            ProductApiClient productApiClient
    ) {
        this.productApiClient = productApiClient;
    }

    @GetMapping("/{id}")
    public ProductResponse getProduct(
            @PathVariable String id
    ) {
        return productApiClient.getProduct(id);
    }
}

最終的な処理フローは次のようになります。

GET /products/100
        │
        ▼
ProductController
        │
        ▼
ProductApiClient
        │
        │ @Retryable
        ▼
RestClient
        │
        ▼
External API
        │
       503
        │
        ▼
RetryableExternalApiException
        │
        ▼
Spring Retry処理
        │
       wait
        │
        ▼
RestClient
        │
        ▼
External API
        │
       200
        │
        ▼
ProductResponse
        │
        ▼
Client

ControllerやService側ではRetryを意識する必要がありません。

外部APIとの通信を担当するClientクラスの責務として閉じ込められるのがポイントです。

Retryをどこに付けるべきか

例えば、

Controller
↓
OrderService
↓
ProductApiClient
↓
External API

という構造なら、

@Retryable
public void createOrder() {
}

のようにOrderService全体へ付けるより、

@Retryable
public ProductResponse getProduct(...) {
}

とProductApiClientへ付けるほうが安全です。

もしService全体をRetryすると、

DB INSERT
↓
外部API
↓
失敗
↓
Retry
↓
DB INSERTをもう一度実行

といった思わぬ再実行につながる可能性があります。

基本的には、

Retryしたい最小単位

へ@Retryableを付けるのがおすすめです。

RetryとCircuit Breakerは別物

Retryについて調べると、

Circuit Breaker

という言葉もよく出てきます。

役割は異なります。

Retryは、

失敗
↓
もう一度試す

仕組みです。

Circuit Breakerは、

失敗
失敗
失敗
↓
外部APIへのアクセスを一時停止

する仕組みです。

例えば外部APIが完全に停止しているとき、

100リクエスト
×
4回実行

= 最大400回外部APIアクセス

となる可能性があります。

Retryが逆に障害を悪化させるケースもあるということです。

そのため大規模なシステムでは、

Timeout
+
Retry
+
Circuit Breaker
+
Concurrency Limit

などを組み合わせて考えることがあります。

Spring Framework 7にはRetryだけでなく@ConcurrencyLimitも用意されています。

Retryの監視も重要

Retryを導入すると、

最終的には成功している

ため、障害に気付きにくくなることがあります。

例えば、

1回目 503
2回目 503
3回目 200

なら、利用者から見ると正常です。

しかし実際には外部APIが不安定になっています。

Spring Framework 7では、Retry対象メソッドで例外が発生するたびにMethodRetryEventが発行されます。

そのため実運用では、

Retry回数
Retry失敗数
最終失敗数
外部APIレスポンスタイム

などを監視するとよいでしょう。

MicrometerやOpenTelemetryと組み合わせると、障害調査もしやすくなります。

実務でおすすめの構成

今回の内容を踏まえると、例えば次の構成が分かりやすいでしょう。

Controller
    │
    ▼
Service
    │
    ▼
ExternalApiClient
    │
    │ @Retryable
    ▼
RestClient
    │
    ▼
External API

Retry処理を、

ExternalApiClient

に閉じ込めます。

さらに、

4xx
↓
Retryしない

5xx
↓
条件に応じてRetry

Connection Timeout
↓
Retryを検討

と例外を分類します。

設定は、

external-api:
  retry:
    max-retries: 3
    delay: 500ms
    multiplier: 2
    max-delay: 3s

のようにapplication.ymlへ切り出します。

これなら、

通信処理
Retry設定
業務ロジック

の責務を分離できます。

まとめ

Spring Framework 7では、Spring Framework本体にRetry機能が追加されました。

基本的には、

@EnableResilientMethods

で機能を有効化し、

@Retryable

を対象メソッドへ付けるだけでRetryできます。

しかし、実務では単純に、

@Retryable

を付ければよいわけではありません。

重要なのは、

  • Retry対象の例外を限定する
  • 4xxと5xxを区別する
  • Retry間隔を設定する
  • 指数バックオフを検討する
  • Jitterを検討する
  • Timeoutを設定する
  • POSTのRetryに注意する
  • 冪等性を考える
  • Retryを監視する

ことです。

特に覚えておきたいのは、

Retryは「失敗したらもう一度実行する機能」ではなく、「一時的な障害から安全に回復するための設計」

という点です。

外部API障害に強いSpring Bootアプリケーションを作るのであれば、

Timeout
↓
Retry
↓
Backoff
↓
Circuit Breaker
↓
Observability

までセットで考えると、より実務的な設計になります。

Spring Framework 7の@Retryableはコード量も少なく、外部API連携の耐障害性を学ぶ入り口として非常に扱いやすい機能です。

ぜひご参考にしてくださいっ!

是非フォローしてください

最新の情報をお伝えします

類似投稿