Test WebClient Timeouts and Bounded Retries with Reactor
On this page
Place a per-attempt timeout before retryWhen, and a total timeout after it to include retry delays in the overall budget. Retry only failures permitted by the API contract: this GET example retries HTTP 503 at most twice and stops on timeouts or other errors. Spring documents transport timeouts separately from Reactor’s publisher timeout.
Which limit does each setting control?
| Setting | Scope in this example |
|---|---|
CONNECT_TIMEOUT_MILLIS |
Establishing a TCP connection; not a complete DNS, pool acquisition or TLS budget |
responseTimeout |
Maximum interval between network reads while receiving a response; not a deadline for the complete body |
First timeout(attemptLimit) |
One subscription through body decoding; starts again on each retry |
Last timeout(totalLimit) |
The complete subscription, including all attempts and retry delays |
StepVerifier.verify(GUARD) |
The test runner’s maximum wait; it does not configure the client |
Reactor Netty’s responseTimeout documentation describes a read interval, so a response that keeps delivering data can exceed that duration in total. The outer publisher timeout supplies a separate overall deadline. Cancellation stops this subscription; it does not undo work already performed by the remote service.
Bound retries before adding more failure cases
The diagram summarizes the operator nesting, rather than every HTTP event. Each attempt succeeds or emits an error. Only a 503 error with retries remaining triggers a delayed resubscription; other errors stop immediately. The outer timeout can cancel either an active attempt or a retry delay.
Retry.fixedDelay(2, delay) permits two retries after the initial attempt: at most three application attempts per subscription. .onRetryExhaustedThrow propagates the last 503 exception rather than a retry-exhausted wrapper. .disableRetry(true) disables Reactor Netty’s separate automatic retry on a TCP abort, so it does not add a hidden transport retry to these counts.
The example performs a GET and deliberately retries only 503. A timeout does not prove that the server did nothing. Do not copy this policy to payments or other writes without an explicit idempotency contract. It does not process Retry-After, retry 429, apply exponential backoff or jitter, or coordinate retries across callers. The short fixed delay makes tests quick; choose production limits and backoff from your API contract and load budget.
Create a standalone project
Use a new empty directory, JDK 17 and Gradle 8.4. The example pins Boot BOM 3.5.7 and MockWebServer 4.12.0 to match the response-contract tests. These are reproduction inputs, not current production version recommendations. Initial dependency resolution uses Maven Central; the HTTP tests use loopback only. No running Boot application or Gradle wrapper is bundled.
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() }
Keep the two deadlines in different positions
Save the following implementation. The constructor takes positive durations selected by the caller; the test supplies explicit values. The response timeout and the 1,000 ms TCP connection timeout belong to the transport factory.
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);
}
}
Reproduce nine policy contracts
Seven tests use a real WebClient and a local MockWebServer. Two use a synthetic exchange and Reactor virtual time to verify operator timing without depending on network scheduling. Virtual time does not test Reactor Netty’s real socket timeouts. The transport test checks the wrapped read-timeout cause, whereas the publisher timeout produces 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());
}
}
Run the project and read the result
gradle --no-daemon clean test
The local reproduction used JDK 17.0.8.1 and Gradle 8.4. All nine tests passed. Inspect build/reports/tests/test/index.html or build/test-results/test/TEST-RetryClientTest.xml for the assertions; BUILD SUCCESSFUL alone does not state how many tests ran.
| Scenario | Checked outcome |
|---|---|
| Immediate success | One HTTP request, ok, then completion |
| 503 → 503 → success | Three HTTP requests, ok, then completion |
| 503 on every attempt | Three HTTP requests, final 503 preserved |
| 404 / 500 | One HTTP request each, status preserved |
| No response, one-second attempt limit | TimeoutException, one HTTP request |
| No response, 250 ms response-read limit | WebClientRequestException with ReadTimeoutException, one request |
| 1.5 s total budget, one-second retry delay | Two synthetic attempts; timeout cancels the delay before a third |
| 400 ms attempt, 200 ms delay, 400 ms attempt | Each attempt fits 500 ms; success and completion checked after advancing virtual time by 1 s |
These checks do not reproduce TCP connection-establishment timeout, DNS failure, TLS negotiation, pool exhaustion, redirects or distributed side effects. The connection limit is configured, not experimentally validated here. OS scheduling can affect real elapsed time; the tests assert error types and request counts, not exact wall-clock milliseconds. For status, media type and JSON handling, continue with the WebClient response-contract example.
Discussion