Handbooks / Spring Boot / Chapter 9
Messaging and HTTP Clients
7 pages · ~5 min✓ Reviewed
Builds on Caching, Async and Scheduling. Next up: Testing and Production.
HTTP Clients: The Mental Model
1. The Two Clients
- RestClient (Spring 6.1+): synchronous, fluent, replaces
RestTemplatein new code - WebClient (spring-webflux): non-blocking, returns
Mono/Flux - WebClient sends nothing until you subscribe
.block()is the escape hatch to wait for the result
java
RestClient rc = RestClient.builder()
.baseUrl("https://api.example.com")
.build();or inject the auto-configured RestClient.Builder
2. Which One to Use
| RestClient | WebClient | RestTemplate | |
|---|---|---|---|
| Model | Blocks a thread | Reactive | Blocks a thread |
| Suits | MVC, virtual threads | Streaming, high fan-out | Maintenance mode |
3. Build and Mistakes
- Build from the injected Builder, not a bare
new - Boot's message converters, observability and customizers then apply
block()inside a reactive pipeline- A new client per request: throws away the connection pool
Build once, reuse
One client per remote service, built from the injected Builder and kept as a bean.
Making Calls and Declarative HTTP Interfaces
1. Fluent calls
- Chain retrieve() then pick the body type
- Generics need
ParameterizedTypeReference toEntity(User.class)when you need status or headers
java
client.get().uri("/users/{id}", id).retrieve().body(User.class); client.get().uri("/users").retrieve() .body(new ParameterizedTypeReference<List<User>>(){}); client.post().uri("/users").contentType(APPLICATION_JSON) .body(dto).retrieve().toBodilessEntity();
| Want | RestClient | WebClient |
|---|---|---|
| One value | body(User.class) | bodyToMono(User.class) |
| Stream | n/a |
Timeouts and Error Handling for Outbound Calls
1. Always Set Timeouts
- Defaults are effectively unbounded
- Set a connect and a read timeout on every client
- No timeout: one slow dependency exhausts the thread pool
2. Configuring Them
| RestClient | WebClient | |
|---|---|---|
| Connect | HttpClient.newBuilder().connectTimeout(...) | option(CONNECT_TIMEOUT_MILLIS, 2000) |
| Read | setReadTimeout(...) | responseTimeout(Duration.ofSeconds(3)) |
| Wrapper | JdkClientHttpRequestFactory |
Kafka Producers and @KafkaListener Consumers
1. Producer
send(topic, key, value)onKafkaTemplate<String,Order>returns a CompletableFuture (non-blocking)- Same key → same partition → per-key order
- Null key: spread across partitions, no ordering
acks=all+enable.idempotence=true: no loss or duplicates on retry- Set
spring.kafka.producer.key-serializerandvalue-serializer(JsonSerializer)
java
kafka.send("orders", o.id(), o) .whenComplete((r, ex) -> { if (ex != null) log.error("failed", ex); });
log failures in whenComplete
2. Consumer
@KafkaListeneron a method; one group splits the partitions- Different groups each get every record
- Concurrency above the partition count = idle consumers
Retries and Dead-Letter Topics
1. Blocking Retries and the DLT
- Default
DefaultErrorHandler: 10 delivery attempts (first + 9 retries), no delay, then log and skip FixedBackOff(1000L, 3)= first delivery + 3 retries, 1 s apart; orExponentialBackOffDeadLetterPublishingRecoverersends the record to <topic>.DLT, same partition, error in headers- Skip retries for bad data with
addNotRetryableExceptions(...) - Wrap the deserializer in ErrorHandlingDeserializer: a poison message goes to the DLT, no endless loop
java
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, Order> t) {
var back = new FixedBackOff(1000L, 3);
var h = new DefaultErrorHandler(new DeadLetterPublishingRecoverer(t), back);
h.addNotRetryableExceptions(ValidationException.class);
return h;
}retry 3 times, then publish to orders.DLT
2. Non-Blocking Retries
- orders
- orders-retry-01 s
- orders-retry-12 s
- orders-retry-24 s
- orders-dlt@DltHandler
Cheat Sheet: Clients and Kafka
1. Clients and Timeouts
| Client | Style | Pick for |
|---|---|---|
RestClient | sync | MVC |
WebClient | reactive | streaming |
@HttpExchange + HttpServiceProxyFactory | typed proxy | declarative calls |
java
User u = restClient.get().uri("/users/{id}", id) .retrieve().body(User.class); Mono<User> m = webClient.get().uri("/users/{id}", id) .retrieve().bodyToMono(User.class);
- Build from the injected Builder
retrieve()throws on 4xx/5xx: useonStatus
Check yourself
A method calls webClient.get().uri("/users/1").retrieve().bodyToMono(User.class); and ignores the result. What request goes out?
- None. A Mono is lazy, so nothing is sent until something subscribes.
- Fix: return the Mono, subscribe, or call
.block()in non-reactive code.
Topic orders has two billing consumers and one audit consumer, each in its own group. One record is published. Who gets it?
- One of the two
billingconsumers, because a group splits the partitions. - The
auditconsumer too, because every group gets every record.
A listener always throws. The error handler is new DefaultErrorHandler(new FixedBackOff(1000L, 3)). How many times does it run, and what then?
- 4 runs: the first delivery plus 3 retries, 1 second apart.
- Then the record is logged and skipped. Add a
DeadLetterPublishingRecovererto send it toorders.DLT.