Advanced WebClient Patterns: Idempotency, Timeouts, and Testing
Advanced WebClient Patterns: Idempotency, Timeouts, and Testing
In the previous article, we covered the base builder pattern for WebClient. Now let’s make it production-ready.
You’ve set up a base WebClient.Builder with logging, retries, and error handling. Great start!
But production systems need more:
- Idempotency keys to prevent duplicate payments
- Custom timeouts for slow operations
- Service-specific error handling for business logic
- Tests to verify everything works
Let’s dive in.
Quick Recap: The Base Builder
In the previous article, we created a base builder:
@Bean
public WebClient.Builder baseWebClientBuilder() {
return WebClient.builder()
.defaultHeader("User-Agent", "MyApp/1.0")
.filter(logRequest())
.filter(logResponse())
.filter(retryWithBackoff())
.filter(mapCommonErrors());
}
Now we’ll add service-specific filters on top of this base.
Real-World Example: Payment Gateway Client
Let’s build a production-ready payment client. Requirements:
- ✅ API key authentication
- ✅ Merchant ID in headers
- ✅ Idempotency keys for POST requests (prevent duplicate charges)
- ✅ Payment-specific errors (insufficient funds, card declined)
The Implementation
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.UUID;
@Configuration
@Slf4j
public class PaymentGatewayConfig {
@Value("${payment.gateway.url}")
private String paymentGatewayUrl;
@Value("${payment.gateway.api-key}")
private String apiKey;
@Value("${payment.gateway.merchant-id}")
private String merchantId;
@Bean
public WebClient paymentGatewayClient(WebClient.Builder baseWebClientBuilder) {
return baseWebClientBuilder
.clone() // Inherits: logging, retries, common errors
.baseUrl(paymentGatewayUrl)
.defaultHeader("X-API-Key", apiKey)
.defaultHeader("X-Merchant-ID", merchantId)
.filter(addIdempotencyKey()) // Payment-specific
.filter(mapPaymentErrors()) // Payment-specific
.build();
}
// Filter 1: Add idempotency key to POST requests
private ExchangeFilterFunction addIdempotencyKey() {
return ExchangeFilterFunction.ofRequestProcessor(request -> {
if (request.method() == HttpMethod.POST) {
String idempotencyKey = UUID.randomUUID().toString();
log.debug("Adding idempotency key: {}", idempotencyKey);
return Mono.just(ClientRequest.from(request)
.header("Idempotency-Key", idempotencyKey)
.build());
}
return Mono.just(request);
});
}
// Filter 2: Map payment-specific errors
private ExchangeFilterFunction mapPaymentErrors() {
return ExchangeFilterFunction.ofResponseProcessor(response -> {
int status = response.statusCode().value();
if (status == 402) { // Payment Required
return response.bodyToMono(String.class)
.defaultIfEmpty("Payment required")
.flatMap(body -> {
log.error("Payment failed - insufficient funds: {}", body);
return Mono.error(new InsufficientFundsException(body));
});
}
if (status == 422) { // Unprocessable Entity
return response.bodyToMono(String.class)
.defaultIfEmpty("Unprocessable entity")
.flatMap(body -> {
log.error("Payment failed - card declined: {}", body);
return Mono.error(new CardDeclinedException(body));
});
}
return Mono.just(response); // Let base error handler deal with others
});
}
}
Using the Payment Client
@Service
@Slf4j
public class PaymentService {
private final WebClient paymentGatewayClient;
public PaymentService(WebClient paymentGatewayClient) {
this.paymentGatewayClient = paymentGatewayClient;
}
public Mono<PaymentResponse> processPayment(PaymentRequest request) {
return paymentGatewayClient
.post()
.uri("/payments")
.bodyValue(request)
.retrieve()
.bodyToMono(PaymentResponse.class)
.doOnSuccess(response ->
log.info("Payment processed: {}", response.getTransactionId()))
.doOnError(InsufficientFundsException.class, ex ->
log.error("Payment failed - insufficient funds"));
}
}
How Filters Execute
Here’s what happens when you call processPayment():
Key insight: Idempotency key is added before logging, so the log shows the actual key sent.
Timeout Configuration
Different APIs need different timeouts. File processing might take 5 minutes, but a payment should respond in seconds.
Base Builder with Timeouts
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.http.client.HttpClient;
@Bean
public WebClient.Builder baseWebClientBuilder() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 5s connect
.responseTimeout(Duration.ofSeconds(10)) // 10s response
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(10)) // 10s read
.addHandlerLast(new WriteTimeoutHandler(10))); // 10s write
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeader("User-Agent", "MyApp/1.0")
.filter(logRequest())
.filter(logResponse())
.filter(retryWithBackoff())
.filter(mapCommonErrors());
}
Override Timeout for Slow Operations
@Bean
public WebClient fileProcessingClient(WebClient.Builder baseWebClientBuilder) {
HttpClient slowHttpClient = HttpClient.create()
.responseTimeout(Duration.ofMinutes(5)); // 5 minute timeout
return baseWebClientBuilder
.clone() // Still inherits all filters!
.clientConnector(new ReactorClientHttpConnector(slowHttpClient))
.baseUrl("https://api.file-processing.com")
.defaultHeader("Authorization", "Bearer ${file.token}")
.build();
}
💡 Tip: Timeout configuration is separate from filters. Overriding the HTTP client doesn’t affect your filter chain.
Testing Your Filters
One huge benefit of separating filters: you can test each one independently.
Example: Testing Idempotency Filter
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.net.URI;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class IdempotencyFilterTest {
@Test
void shouldAddKeyToPostRequests() {
// Arrange
ExchangeFilterFunction filter = addIdempotencyKey();
ExchangeFunction mockExchange = mock(ExchangeFunction.class);
ClientRequest request = ClientRequest
.create(HttpMethod.POST, URI.create("http://test.com"))
.build();
// Act & Assert
StepVerifier.create(filter.filter(request, mockExchange))
.assertNext(modifiedRequest -> {
ClientRequest req = (ClientRequest) modifiedRequest;
assertThat(req.headers()).containsKey("Idempotency-Key");
})
.verifyComplete();
}
@Test
void shouldNotAddKeyToGetRequests() {
// Arrange
ExchangeFilterFunction filter = addIdempotencyKey();
ExchangeFunction mockExchange = mock(ExchangeFunction.class);
ClientRequest request = ClientRequest
.create(HttpMethod.GET, URI.create("http://test.com"))
.build();
// Act & Assert
StepVerifier.create(filter.filter(request, mockExchange))
.assertNext(modifiedRequest -> {
ClientRequest req = (ClientRequest) modifiedRequest;
assertThat(req.headers()).doesNotContainKey("Idempotency-Key");
})
.verifyComplete();
}
}
Why this matters:
✅ Test filters without making actual HTTP calls
✅ Verify behavior for different HTTP methods
✅ When a filter breaks, you know exactly which one
Common Pitfalls and How to Avoid Them
1. ⚠️ Logging Request/Response Bodies
// DANGEROUS: This breaks reactive streams!
private ExchangeFilterFunction logRequestBody() {
return ExchangeFilterFunction.ofRequestProcessor(request -> {
return request.body() // This consumes the body!
.doOnNext(body -> log.info("Body: {}", body))
.then(Mono.just(request));
});
}
The problem: You can only read a reactive stream once. After logging, the body is gone.
The fix: Use ExchangeStrategies to buffer the body, or don’t log it at all.
💡 Tip: For debugging, use a proxy like Charles or Wireshark instead of logging bodies in code.
2. ⚠️ Too Many Filters
// HARD TO DEBUG: 10+ filters in the chain
.filter(logRequest())
.filter(addCorrelationId())
.filter(addTraceId())
.filter(addUserAgent())
.filter(addAcceptHeader())
.filter(addContentType())
.filter(validateRequest())
.filter(retryWithBackoff())
.filter(circuitBreaker())
.filter(rateLimiting())
.filter(mapErrors())
.filter(logResponse())
The problem: When something breaks, good luck figuring out which filter caused it.
The fix: Combine related concerns. Headers can be set with .defaultHeader() instead of filters.
3. ⚠️ Retry Without Idempotency
We mentioned this in the previous article, but it’s worth repeating:
// DANGEROUS: Can duplicate orders/payments!
public Mono<OrderResponse> createOrder(OrderRequest request) {
return webClient
.post()
.uri("/orders")
.bodyValue(request)
.retrieve()
.bodyToMono(OrderResponse.class);
// If this times out after the order is created,
// the retry will create a SECOND order!
}
The fix: Always add idempotency keys to POST/PUT/PATCH requests that modify state.
⚠️ Warning: GET and DELETE are naturally idempotent. POST, PUT, and PATCH are NOT.
When to Extract to a Starter
Should you extract your base WebClient configuration into a custom Spring Boot starter?
✅ Extract to a Starter If:
- You have 3+ microservices that all call external APIs
- You want consistent behavior across all services (same logging, retry logic, error handling)
- You have company-wide standards for HTTP client configuration
- You’re tired of copy-pasting the base configuration into every new service
❌ Don’t Extract If:
- You only have 1-2 services – just keep the configuration in your service
- Each service has wildly different requirements – the base configuration won’t help much
- You’re still experimenting – wait until the pattern stabilizes
The Honest Truth
I’ve seen teams go both ways. Some extract too early and end up with a starter nobody uses. Others wait too long and waste time copy-pasting.
My rule of thumb: If you’re about to copy-paste the same WebClient configuration into a third service, that’s when you extract it to a starter.
Visualizing the Request-Response Flow
Here’s how a request flows through your filter chain:
Key insight: Request filters modify the request before it’s sent. Response filters handle the response after it’s received.
Key Takeaways
- Add idempotency keys to POST requests to prevent duplicates
- Configure timeouts based on the operation (fast vs. slow APIs)
- Test filters independently – they’re pure functions
- Don’t log request/response bodies – it breaks reactive streams
- Keep filter chains short – 4-6 filters is plenty
- Extract to a starter when you have 3+ services with similar needs
- Service-specific filters go on top of base filters (payment errors, rate limiting, etc.)
What We’ve Covered
In this two-part series, we’ve built a production-ready WebClient configuration:
Part 1: Base builder pattern with logging, retries, and error handling Part 2: Idempotency, timeouts, testing, and common pitfalls
You now have everything you need to build robust HTTP clients in Spring Boot microservices.
Thanks for reading!
The patterns in this article come from real production systems. Use them to build reliable, maintainable HTTP clients.
Questions or feedback? Let me know what patterns you’re using in your projects!