HTTP Clients & Resilience¶
Every remote call can be slow, fail, or fail slowly — the worst case, because it ties up your threads while you wait. This lesson covers Spring's modern HTTP clients and the resilience techniques that keep one misbehaving dependency from taking your service down with it.
Choosing a client¶
| Client | Style | Use it for |
|---|---|---|
RestClient (Framework 6.1+) |
Synchronous, fluent | The default choice in Spring MVC apps, especially with virtual threads |
WebClient |
Reactive (Mono/Flux) |
WebFlux apps, or streaming responses |
| HTTP interfaces | Declarative Java interface | Most service-to-service calls; backed by either client |
RestTemplate |
Synchronous, template methods | Existing code; in maintenance mode, not for new code |
RestClient¶
@Configuration
class PaymentClientConfig {
@Bean
RestClient paymentRestClient(RestClient.Builder builder,
@Value("${payments.base-url}") String baseUrl) {
var settings = HttpClientSettings.defaults() // Boot 4; Boot 3.4+: ClientHttpRequestFactorySettings
.withConnectTimeout(Duration.ofSeconds(1))
.withReadTimeout(Duration.ofSeconds(2));
return builder
.baseUrl(baseUrl)
.requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
@Component
class PaymentGateway {
private final RestClient http;
PaymentGateway(RestClient paymentRestClient) { this.http = paymentRestClient; }
PaymentResult charge(ChargeRequest request) {
return http.post()
.uri("/charges")
.body(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
throw new PaymentRejectedException(res.getStatusCode());
})
.body(PaymentResult.class);
}
}
- Inject Boot's auto-configured
RestClient.Builderrather than callingRestClient.create(): the builder carries Boot's message converters and, when present, observation (metrics and tracing) instrumentation. - Always set timeouts. A connect timeout bounds how long to wait for a TCP connection;
a read timeout bounds silence while waiting for bytes. Defaults vary by underlying HTTP
library and can be very long or infinite.
HttpClientSettingsandClientHttpRequestFactoryBuilderlive inorg.springframework.boot.http.client(Boot 4'sspring-boot-http-clientmodule). Boot can also apply timeouts globally from properties; those property names have changed between Boot releases, so check the reference documentation for your version. retrieve()throwsRestClientResponseExceptionsubtypes for 4xx/5xx unless you handle them withonStatus.
HTTP interfaces¶
Describe the remote API as an interface:
@HttpExchange("/charges")
interface PaymentsApi {
@PostExchange
PaymentResult charge(@RequestBody ChargeRequest request);
@GetExchange("/{id}")
PaymentResult get(@PathVariable String id);
}
and let Spring implement it:
@Bean
PaymentsApi paymentsApi(RestClient paymentRestClient) {
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(paymentRestClient))
.build()
.createClient(PaymentsApi.class);
}
Spring Framework 7 adds @ImportHttpServices to register groups of such interfaces with
less code, and Boot 4 can configure those groups from properties. The interface form is
easy to fake in tests (it's just an interface) and keeps call sites clean.
Resilience patterns¶
| Pattern | Protects against | Idea |
|---|---|---|
| Timeout | Slow dependencies | Give up after a bound |
| Retry with backoff + jitter | Transient failures (a dropped connection, a 503) | Try again, waiting longer each time, randomized so clients don't retry in lockstep |
| Circuit breaker | A dependency that is down | After many failures, fail fast for a while instead of calling; probe periodically |
| Bulkhead / concurrency limit | One dependency consuming all threads | Cap concurrent calls to it |
| Fallback | Degradable features | Return a cached or default answer |
Only retry idempotent operations — or make them idempotent. Retrying POST /charges
after a read timeout may charge the customer twice, because the first request might have
succeeded. Send an idempotency key the server deduplicates on.
Built into Spring Framework 7¶
@Configuration
@EnableResilientMethods
class ResilienceConfig { }
@Component
class ExchangeRates {
@Retryable(maxRetries = 3, delay = 200, multiplier = 2, jitter = 50,
includes = ResourceAccessException.class)
@ConcurrencyLimit(10)
public Rates latest() { ... }
}
@Retryable and @ConcurrencyLimit (package org.springframework.resilience.annotation)
cover retries and bulkheads without extra libraries. There is also a programmatic
RetryTemplate in org.springframework.core.retry.
Resilience4j for circuit breakers¶
For circuit breakers, rate limiters, and time limiters, Resilience4j is the standard
choice. It publishes Spring Boot integration modules per Boot generation
(resilience4j-spring-boot3, resilience4j-spring-boot4) plus the Spring Cloud Circuit
Breaker abstraction:
resilience4j:
circuitbreaker:
instances:
payments:
sliding-window-size: 20
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
@CircuitBreaker(name = "payments", fallbackMethod = "chargeLater")
PaymentResult charge(ChargeRequest request) { ... }
PaymentResult chargeLater(ChargeRequest request, CallNotPermittedException ex) {
return PaymentResult.pending(request.idempotencyKey()); // queue for later
}
The annotation-based integration needs AOP on the classpath (spring-boot-starter-aspectj
in Boot 4). The Resilience4j examples in this lesson were not run for the course; check
the Resilience4j documentation for the module matching your Boot version.
Worked example: testing a client against a fake server¶
MockRestServiceServer intercepts a RestClient built from the same builder:
@RestClientTest(PaymentGateway.class)
class PaymentGatewayTest {
@Autowired PaymentGateway gateway;
@Autowired MockRestServiceServer server;
@Test
void rejectedCardBecomesDomainException() {
server.expect(requestTo(endsWith("/charges")))
.andRespond(withStatus(HttpStatus.PAYMENT_REQUIRED));
assertThatThrownBy(() -> gateway.charge(new ChargeRequest("k-1", 1999)))
.isInstanceOf(PaymentRejectedException.class);
}
}
For timeout behavior, use a real local HTTP server that delays (WireMock is common) —
MockRestServiceServer bypasses the network, so it cannot exercise timeouts.
How It Actually Works¶
A RestClient is a thin fluent layer over a ClientHttpRequestFactory — an adapter
for an underlying HTTP library (the JDK HttpClient, Apache HttpClient 5, Jetty, or
Reactor Netty). ClientHttpRequestFactoryBuilder.detect() picks one based on what is on
the classpath. Requests pass through ClientHttpRequestInterceptors (where observation,
auth headers, and logging hook in), then the factory's connection pool; the response body
is converted by the same HttpMessageConverters your controllers use.
A circuit breaker is a small state machine around calls. Closed: calls pass through and
outcomes are recorded in a sliding window (count- or time-based). When the failure rate
(or slow-call rate) exceeds the threshold, it transitions to Open: calls fail
immediately with CallNotPermittedException, which costs microseconds instead of a
timeout. After the wait duration it moves to Half-open and allows a few trial calls;
success closes the circuit, failure reopens it. The annotation versions are implemented as
AOP proxies — with the usual self-invocation caveat.
Common mistakes¶
- No timeouts. The default is rarely what you want.
- Retrying non-idempotent calls without an idempotency key.
- Retries stacked on retries (client, gateway, and service each retry 3 times → 27 calls to a struggling service).
- Circuit breaker thresholds set without data. Look at real error rates and latency.
- Using
RestClient.create()and losing Boot's instrumentation.
Exercise¶
- Build a
PaymentsApiHTTP interface over aRestClientwith 1 s connect and 2 s read timeouts. Point it at a WireMock (or a tiny second Boot app) that sleeps 5 s, and confirm the call fails after about 2 s. - Add
@RetryableforResourceAccessExceptionwith backoff and jitter; log each attempt. - Add an idempotency key header to charges and explain in a comment why retries are now safe.
- Add a Resilience4j circuit breaker with a fallback, force failures, and watch the state
transitions via Actuator (
/actuator/circuitbreakerswith the Resilience4j module).