Reactor で WebClient のタイムアウトと上限付きリトライをテストする
この記事の目次
試行ごとの timeout を retryWhen の前に、全体の timeout をその後に配置し、再試行の待機時間も全体の予算に含めます。API 契約で許される失敗だけを再試行します。この GET の例は HTTP 503 を最大二回再試行し、タイムアウトやその他のエラーでは停止します。Spring はトランスポートのタイムアウトを Reactor のPublisher のタイムアウトと分けて説明しています。
各設定は何を制限するか
| 設定 | この例での対象範囲 |
|---|---|
CONNECT_TIMEOUT_MILLIS |
TCP 接続の確立。DNS、プールからの取得、TLS 全体の予算ではない |
responseTimeout |
応答の受信中にネットワークから読み取る間隔の最大値。本文全体の期限ではない |
最初の timeout(attemptLimit) |
本文のデコードまでを含む 一回の購読。再試行ごとに計時し直す |
最後の timeout(totalLimit) |
すべての試行と再試行の待機時間を含む購読全体 |
StepVerifier.verify(GUARD) |
テストランナーの最大待機時間。クライアントの設定ではない |
Reactor Netty の responseTimeout のドキュメントは読み取り間隔を説明しています。そのため、継続的にデータが届く応答の総時間は、この値を超えることがあります。外側の Publisher のタイムアウトは、全体に対する別の期限を設けます。キャンセルはこの購読を止めますが、リモートサービスがすでに行った処理は取り消しません。
失敗ケースを増やす前に再試行の上限を決める
図は個々の HTTP イベントではなく、オペレーターの入れ子をまとめたものです。各試行は成功するか、エラーを通知します。再試行回数が残っている 503 エラーだけが、待機後の再購読を引き起こします。それ以外のエラーではすぐに停止します。外側のタイムアウトは実行中の試行も、再試行の待機もキャンセルできます。
Retry.fixedDelay(2, delay)は、最初の試行の後に 二回の再試行を許可します。一回の購読につきアプリケーションの試行は最大三回です。.onRetryExhaustedThrow は再試行上限のラッパー例外ではなく、最後の 503 例外を伝播します。.disableRetry(true) は TCP 切断時に Reactor Netty が別途行う自動再試行を無効にし、この回数に隠れたトランスポートの再試行が加わらないようにします。
この例は GET を実行し、意図的に 503 だけを再試行します。タイムアウトしても、サーバーが何もしていないとは限りません。明示的な冪等性の契約がないまま、決済やその他の書き込みにこのポリシーを流用しないでください。Retry-After の処理、429 の再試行、指数バックオフやジッター、呼び出し元間での再試行の調整は行いません。短い固定待機時間はテストを速くするためのものです。本番の制限とバックオフは API 契約と負荷の予算から選んでください。
単独で動くプロジェクトを作る
新しい空のディレクトリ、JDK 17、Gradle 8.4 を使います。Boot BOM 3.5.7 と MockWebServer 4.12.0 に固定し、応答契約のテストに合わせています。これは再現用の条件であり、現時点の本番推奨バージョンではありません。最初の依存解決は Maven Central を利用し、HTTP テストはループバックだけを使います。起動済みの Boot アプリケーションや Gradle wrapper は含みません。
settings.gradle
rootProject.name = 'webclient-timeout-retry'
build.gradle
plugins { id 'java' }
repositories { mavenCentral() }
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:3.5.7')
implementation 'org.springframework.boot:spring-boot-starter-webflux'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test { useJUnitPlatform() }
二つの期限を異なる位置に置く
次の実装を保存します。コンストラクターは呼び出し元が選ぶ正の期間を受け取り、テストでは値を明示します。応答のタイムアウトと 1,000 ms の TCP 接続タイムアウトは、トランスポートファクトリーの設定です。
src/main/java/RetryClient.java
import java.time.Duration;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.util.retry.Retry;
public class RetryClient {
private final WebClient client;
private final Duration attemptLimit;
private final Duration totalLimit;
private final Duration retryDelay;
public RetryClient(WebClient client, Duration attemptLimit,
Duration totalLimit, Duration retryDelay) {
this.client = client;
this.attemptLimit = attemptLimit;
this.totalLimit = totalLimit;
this.retryDelay = retryDelay;
}
public static WebClient transport(String baseUrl, Duration responseLimit) {
HttpClient http = HttpClient.create()
.disableRetry(true)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.responseTimeout(responseLimit);
return WebClient.builder().baseUrl(baseUrl)
.clientConnector(new ReactorClientHttpConnector(http)).build();
}
public Mono<String> get() {
return Mono.defer(() -> client.get().uri("/value")
.retrieve().bodyToMono(String.class))
.timeout(attemptLimit)
.retryWhen(Retry.fixedDelay(2, retryDelay)
.filter(error -> error instanceof WebClientResponseException response
&& response.getStatusCode().value() == 503)
.onRetryExhaustedThrow((spec, signal) -> signal.failure()))
.timeout(totalLimit);
}
}
九つのポリシー契約を再現する
七つのテストは実際の WebClient とローカルの MockWebServer を使います。二つは合成した exchange と Reactor の仮想時間を使い、ネットワークのスケジューリングに依存せずオペレーターのタイミングを検証します。仮想時間は Reactor Netty の実際のソケットタイムアウトをテストしません。トランスポートのテストはラップされた読み取りタイムアウトの原因を検査し、Publisher のタイムアウトは TimeoutException を生成します。
src/test/java/RetryClientTest.java
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import io.netty.handler.timeout.ReadTimeoutException;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.SocketPolicy;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientRequestException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.test.StepVerifier;
import static org.junit.jupiter.api.Assertions.*;
class RetryClientTest {
private MockWebServer server;
private WebClient client;
private static final Duration GUARD = Duration.ofSeconds(10);
@BeforeAll
static void warmUp() {
HttpClient.create().warmup().block(GUARD);
}
@BeforeEach
void start() throws IOException {
server = new MockWebServer();
server.start();
client = RetryClient.transport(server.url("/").toString(), Duration.ofSeconds(5));
}
@AfterEach
void stop() throws IOException {
server.shutdown();
}
RetryClient policy() {
return new RetryClient(client, Duration.ofSeconds(3),
Duration.ofSeconds(8), Duration.ofMillis(10));
}
void expectStatus(int status) {
StepVerifier.create(policy().get())
.expectErrorSatisfies(error -> assertEquals(status,
assertInstanceOf(WebClientResponseException.class, error).getStatusCode().value()))
.verify(GUARD);
}
@Test
void successUsesOneRequest() {
server.enqueue(new MockResponse().setBody("ok"));
StepVerifier.create(policy().get()).expectNext("ok").expectComplete().verify(GUARD);
assertEquals(1, server.getRequestCount());
}
@Test
void two503ResponsesThenSuccessUseThreeRequests() {
server.enqueue(new MockResponse().setResponseCode(503));
server.enqueue(new MockResponse().setResponseCode(503));
server.enqueue(new MockResponse().setBody("ok"));
StepVerifier.create(policy().get()).expectNext("ok").expectComplete().verify(GUARD);
assertEquals(3, server.getRequestCount());
}
@Test
void exhausted503PreservesLastStatusAfterThreeRequests() {
for (int i = 0; i < 3; i++) server.enqueue(new MockResponse().setResponseCode(503));
expectStatus(503);
assertEquals(3, server.getRequestCount());
}
@Test
void notFoundIsNotRetried() {
server.enqueue(new MockResponse().setResponseCode(404));
expectStatus(404);
assertEquals(1, server.getRequestCount());
}
@Test
void otherServerErrorsAreNotRetried() {
server.enqueue(new MockResponse().setResponseCode(500));
expectStatus(500);
assertEquals(1, server.getRequestCount());
}
@Test
void attemptTimeoutIsNotRetried() {
server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
RetryClient limited = new RetryClient(client, Duration.ofSeconds(1),
Duration.ofSeconds(8), Duration.ofMillis(10));
StepVerifier.create(limited.get()).expectError(TimeoutException.class).verify(GUARD);
assertEquals(1, server.getRequestCount());
}
@Test
void responseReadTimeoutIsNotRetried() {
server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
WebClient shortRead = RetryClient.transport(server.url("/").toString(), Duration.ofMillis(250));
RetryClient limited = new RetryClient(shortRead, Duration.ofSeconds(3),
Duration.ofSeconds(8), Duration.ofMillis(10));
StepVerifier.create(limited.get()).expectErrorSatisfies(error -> {
assertInstanceOf(WebClientRequestException.class, error);
assertInstanceOf(ReadTimeoutException.class, error.getCause());
}).verify(GUARD);
assertEquals(1, server.getRequestCount());
}
@Test
void totalBudgetIncludesRetryDelay() {
AtomicInteger attempts = new AtomicInteger();
StepVerifier.withVirtualTime(() -> {
WebClient synthetic = WebClient.builder().exchangeFunction(request -> Mono.defer(() -> {
attempts.incrementAndGet();
return Mono.just(ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE).build());
})).build();
return new RetryClient(synthetic, Duration.ofSeconds(1),
Duration.ofMillis(1500), Duration.ofSeconds(1)).get();
}).thenAwait(Duration.ofMillis(1500)).expectError(TimeoutException.class).verify(GUARD);
assertEquals(2, attempts.get());
}
@Test
void attemptBudgetRestartsAfterRetry() {
AtomicInteger attempts = new AtomicInteger();
StepVerifier.withVirtualTime(() -> {
WebClient synthetic = WebClient.builder().exchangeFunction(request -> Mono.defer(() -> {
int attempt = attempts.incrementAndGet();
return Mono.delay(Duration.ofMillis(400)).map(tick -> attempt == 1
? ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE).build()
: ClientResponse.create(HttpStatus.OK).body("ok").build());
})).build();
return new RetryClient(synthetic, Duration.ofMillis(500),
Duration.ofSeconds(2), Duration.ofMillis(200)).get();
}).thenAwait(Duration.ofSeconds(1)).expectNext("ok").expectComplete().verify(GUARD);
assertEquals(2, attempts.get());
}
}
プロジェクトを実行して結果を読む
gradle --no-daemon clean test
ローカルでの再現には JDK 17.0.8.1 と Gradle 8.4 を使用し、九つすべてのテストに成功しました。アサーションは build/reports/tests/test/index.html または build/test-results/test/TEST-RetryClientTest.xml で確認します。BUILD SUCCESSFUL だけでは実行されたテスト数は分かりません。
| シナリオ | 確認した結果 |
|---|---|
| 即時成功 | HTTP リクエスト一回、ok、完了 |
| 503 → 503 → 成功 | HTTP リクエスト三回、ok、完了 |
| すべての試行で 503 | HTTP リクエスト三回、最後の 503 を保持 |
| 404 / 500 | それぞれ HTTP リクエスト一回、ステータスを保持 |
| 応答なし、試行の上限は一秒 | TimeoutException、HTTP リクエスト一回 |
| 応答なし、応答読み取りの上限は 250 ms | WebClientRequestException。原因は ReadTimeoutException、リクエスト一回 |
| 全体の予算 1.5 s、再試行の待機一秒 | 合成した試行二回。三回目の前にタイムアウトが待機をキャンセル |
| 試行 400 ms、待機 200 ms、試行 400 ms | 各試行は 500 ms 以内。仮想時間を 1 s 進めた後に成功と完了を確認 |
これらの検査は、TCP 接続確立のタイムアウト、DNS 障害、TLS ネゴシエーション、プールの枯渇、リダイレクト、分散した副作用を再現しません。接続の上限は設定してありますが、ここで実験的に検証してはいません。OS のスケジューリングは実際の経過時間に影響します。テストは正確な実時間のミリ秒数ではなく、エラーの種類とリクエスト数をアサートします。ステータス、メディアタイプ、JSON の扱いは、WebClient の応答契約の例を参照してください。
コメント