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 Gateway | Retry候補 | upstream障害の可能性 |
| 503 Service Unavailable | Retry候補 | 一時停止・過負荷の可能性 |
| 504 Gateway Timeout | Retry候補 | 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連携の耐障害性を学ぶ入り口として非常に扱いやすい機能です。
ぜひご参考にしてくださいっ!
是非フォローしてください
最新の情報をお伝えします
