Spring RestClient: Synchronous HTTP Done Properly
Learn Java Microservices Communication - Part 020
Production-grade guide to using Spring RestClient as a modern synchronous HTTP client for Java microservice communication.
Part 020 — Spring RestClient: Synchronous HTTP Done Properly
Spring RestClient exists because a lot of production Java systems still want a synchronous HTTP programming model.
That is not automatically wrong.
The mistake is not synchronous HTTP.
The mistake is synchronous HTTP without:
timeouts
status classification
error mapping
header policy
observability
bulkhead policy
retry safety
contract boundaries
RestClient gives Spring applications a modern fluent API for blocking HTTP calls. It integrates with Spring's HTTP abstractions, message conversion, request factories, interceptors, and status handlers.
But it does not remove distributed-system failure.
This part shows how to use RestClient as a production service-client building block, not as a convenient one-liner generator.
1. What RestClient Is
RestClient is Spring Framework's modern synchronous HTTP client API.
Its mental model:
RestClient
= fluent synchronous HTTP facade
+ request builder API
+ message conversion
+ interceptors
+ status handlers
+ pluggable underlying HTTP client
Example:
RestClient client = RestClient.builder()
.baseUrl("https://catalog.internal")
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
ProductView product = client.get()
.uri("/v1/products/{id}", productId)
.retrieve()
.body(ProductView.class);
This is readable. It is also dangerously incomplete if copied directly into production code.
The production version must answer:
What happens on 404?
What happens on 409?
What happens on 429?
What happens on 503?
What happens on timeout?
Where are trace headers added?
Where are metrics emitted?
Where is the base URL configured?
Where is the response-size policy?
Can this call be retried?
RestClient is a good API. It is not a communication architecture by itself.
2. RestClient vs RestTemplate vs WebClient
Spring applications historically had three common choices:
| Client | Model | Typical use |
|---|---|---|
RestTemplate | synchronous template API | legacy blocking HTTP code |
RestClient | synchronous fluent API | new blocking HTTP clients |
WebClient | reactive/non-blocking API | reactive pipelines, streaming, high concurrency with Reactor |
Use RestClient when:
- the surrounding application is imperative;
- the call volume is moderate and controlled;
- you want simple synchronous code;
- you use Spring MVC or regular service-layer code;
- you do not need reactive composition;
- virtual threads or bounded platform-thread pools are sufficient;
- you want Spring message conversion and interceptors.
Use WebClient when:
- the application is already reactive;
- you need non-blocking composition;
- you need streaming/backpressure integration;
- you need advanced reactive timeout/retry operators;
- the entire call path can remain reactive.
Do not choose WebClient just because “non-blocking sounds more scalable”.
A badly designed reactive client can overload dependencies just as easily as a badly designed blocking client.
3. The Right Boundary
Never inject a generic RestClient into random services and build URLs everywhere.
Bad:
@Service
class OrderService {
private final RestClient restClient;
public void submitOrder(String productId) {
ProductView product = restClient.get()
.uri("https://catalog.internal/v1/products/{id}", productId)
.retrieve()
.body(ProductView.class);
}
}
This hides several problems:
- service URL is embedded in business code;
- status mapping is not explicit;
- error model is whatever default behavior happens;
- observability labels are not standardized;
- no dependency-specific policy;
- tests become brittle;
- multiple call sites drift.
Use a typed gateway:
public interface CatalogGateway {
Optional<ProductView> findProduct(String productId, RequestContext context);
}
Implementation:
@Component
public final class RestClientCatalogGateway implements CatalogGateway {
private final RestClient catalog;
public RestClientCatalogGateway(@Qualifier("catalogRestClient") RestClient catalog) {
this.catalog = catalog;
}
@Override
public Optional<ProductView> findProduct(String productId, RequestContext context) {
// HTTP details stay here
}
}
The rest of the application depends on CatalogGateway, not raw HTTP.
4. Configure One RestClient per Dependency
Do not use one global RestClient for every dependency unless all dependencies share the same communication policy.
Usually they do not.
Create one client per dependency or per policy group:
@Configuration
class CatalogClientConfiguration {
@Bean
RestClient catalogRestClient(RestClient.Builder builder, CatalogClientProperties properties) {
return builder
.baseUrl(properties.baseUrl().toString())
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultRequest(request -> {
request.header("X-Client-Name", "order-service");
})
.build();
}
}
This gives you a stable place for:
base URL
default headers
message converters
interceptors
status handlers
underlying HTTP client/request factory
observability policy
Avoid static RestClient.create() scattered through code.
5. Configuration Properties
Use typed configuration.
@ConfigurationProperties(prefix = "clients.catalog")
public record CatalogClientProperties(
URI baseUrl,
Duration connectTimeout,
Duration readTimeout,
Duration requestTimeout,
int maxAttempts,
int maxResponseBytes
) {
public CatalogClientProperties {
Objects.requireNonNull(baseUrl);
Objects.requireNonNull(connectTimeout);
Objects.requireNonNull(readTimeout);
Objects.requireNonNull(requestTimeout);
if (connectTimeout.isNegative() || connectTimeout.isZero()) {
throw new IllegalArgumentException("connectTimeout must be positive");
}
if (readTimeout.isNegative() || readTimeout.isZero()) {
throw new IllegalArgumentException("readTimeout must be positive");
}
if (requestTimeout.isNegative() || requestTimeout.isZero()) {
throw new IllegalArgumentException("requestTimeout must be positive");
}
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be at least 1");
}
if (maxResponseBytes <= 0) {
throw new IllegalArgumentException("maxResponseBytes must be positive");
}
}
}
Example YAML:
clients:
catalog:
base-url: https://catalog.internal
connect-timeout: 300ms
read-timeout: 700ms
request-timeout: 900ms
max-attempts: 2
max-response-bytes: 1048576
Configuration should express communication policy in one place.
If engineers must inspect twenty call sites to understand timeout behavior, the system is already weak.
6. Underlying HTTP Client Matters
RestClient is a facade over an underlying HTTP client/request factory.
That means the real transport behavior depends on what is underneath.
Common choices include:
JDK HttpClient
Apache HttpComponents
Jetty
Simple JDK URLConnection-based factory
The factory affects:
- connect timeout support;
- read/response timeout support;
- connection pool behavior;
- TLS behavior;
- proxy support;
- HTTP/2 support;
- metrics hooks;
- low-level socket behavior.
Do not configure RestClient as if all underlying factories behave identically.
A production configuration must name the actual transport.
Example using the JDK client factory shape:
@Bean
RestClient catalogRestClient(CatalogClientProperties properties) {
HttpClient jdkClient = HttpClient.newBuilder()
.connectTimeout(properties.connectTimeout())
.version(HttpClient.Version.HTTP_2)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(jdkClient);
requestFactory.setReadTimeout(properties.readTimeout());
return RestClient.builder()
.baseUrl(properties.baseUrl().toString())
.requestFactory(requestFactory)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
If you use Apache HttpComponents, configure its connection manager, pool limits, connection request timeout, connect timeout, and response timeout deliberately.
Do not assume Spring's fluent API gives you a safe pool policy automatically.
7. Default Headers
Use default headers for stable client identity and content negotiation.
RestClient catalog = RestClient.builder()
.baseUrl(properties.baseUrl().toString())
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("X-Client-Name", "order-service")
.build();
Good default headers:
Accept
User-Agent or X-Client-Name
API version header if used
Bad default headers:
Authorization for all calls regardless of target
Idempotency-Key reused across operations
X-Tenant-Id from static config
traceparent generated once at startup
Correlation ID generated once per client
Request-scoped context must be request-scoped.
8. Request Context Propagation
Use defaultRequest for generic request customization, but request-specific context usually needs to be applied at call time.
Example:
private RestClient.RequestHeadersSpec<?> applyContext(
RestClient.RequestHeadersSpec<?> spec,
RequestContext context
) {
spec.header("traceparent", context.traceparent());
context.tracestate().ifPresent(value -> spec.header("tracestate", value));
spec.header("X-Correlation-Id", context.correlationId());
context.tenantId().ifPresent(value -> spec.header("X-Tenant-Id", value));
context.actorId().ifPresent(value -> spec.header("X-Actor-Id", value));
context.idempotencyKey().ifPresent(value -> spec.header("Idempotency-Key", value));
long remainingMillis = Math.max(0, Duration.between(Instant.now(), context.deadline()).toMillis());
spec.header("X-Request-Timeout-Ms", Long.toString(remainingMillis));
return spec;
}
Usage:
ProductView product = applyContext(
catalog.get().uri("/v1/products/{id}", productId),
context
)
.retrieve()
.body(ProductView.class);
But avoid making every method manually repeat this.
For larger systems, use an interceptor or a service-client helper so propagation is consistent.
9. Interceptors
RestClient can use interceptors through Spring's client HTTP abstraction.
An interceptor is a good place for cross-cutting behavior:
trace propagation
correlation ID injection
client identity
metrics start/stop
structured logging
header redaction
Example skeleton:
public final class OutboundContextInterceptor implements ClientHttpRequestInterceptor {
private final RequestContextHolder contextHolder;
public OutboundContextInterceptor(RequestContextHolder contextHolder) {
this.contextHolder = contextHolder;
}
@Override
public ClientHttpResponse intercept(
HttpRequest request,
byte[] body,
ClientHttpRequestExecution execution
) throws IOException {
RequestContext context = contextHolder.current();
HttpHeaders headers = request.getHeaders();
headers.set("traceparent", context.traceparent());
headers.set("X-Correlation-Id", context.correlationId());
context.tenantId().ifPresent(value -> headers.set("X-Tenant-Id", value));
return execution.execute(request, body);
}
}
Register:
RestClient client = RestClient.builder()
.baseUrl(properties.baseUrl().toString())
.requestInterceptor(new OutboundContextInterceptor(contextHolder))
.build();
Keep interceptors boring.
Do not put domain decisions inside interceptors. Do not make interceptors call databases. Do not make interceptors execute blocking token refresh without a timeout. Do not hide retries in an interceptor unless the whole organization treats it as the official retry policy.
10. retrieve() vs exchange()
The retrieve() flow is concise:
ProductView product = catalog.get()
.uri("/v1/products/{id}", productId)
.retrieve()
.body(ProductView.class);
It is good when:
- success status mapping is simple;
- default error handling is enough or configured;
- you want direct body extraction.
exchange() gives more control:
ProductView product = catalog.get()
.uri("/v1/products/{id}", productId)
.exchange((request, response) -> {
if (response.getStatusCode().is2xxSuccessful()) {
return read(response.getBody(), ProductView.class);
}
throw mapError(response);
});
Use exchange() when you need:
- custom status classification;
- access to headers before body mapping;
- special 404/409/422 behavior;
- response-size controls;
- different body parsing per status;
- custom exception mapping.
A production client often uses retrieve() for simple query APIs and exchange() for command APIs where outcome semantics matter.
11. Status Handler Policy
You can register default status handlers.
Example:
RestClient catalog = RestClient.builder()
.baseUrl(properties.baseUrl().toString())
.defaultStatusHandler(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new DownstreamFailureException("catalog returned " + response.getStatusCode());
})
.defaultStatusHandler(status -> status.value() == 429, (request, response) -> {
throw new DownstreamRateLimitedException("catalog rate limited");
})
.build();
This is useful, but be careful.
A global handler per client can accidentally erase operation-specific semantics.
Example:
GET /v1/products/{id} 404 -> product not found, valid business outcome
GET /v1/categories/{id} 404 -> category not found, valid business outcome
POST /v1/reservations 404 -> referenced item missing or route bug depending on contract
The same status can mean different things by operation.
Recommended layering:
default handler:
protocol-wide failure classes such as 500, 502, 503, 504, malformed response
operation-level handling:
404, 409, 422, 202, 204, domain-specific outcomes
Do not force all statuses into one global exception map.
12. Problem Details Mapping
If your internal APIs use RFC 9457 Problem Details, map them consistently.
Type:
public record ProblemDetails(
URI type,
String title,
int status,
String detail,
String instance,
Map<String, Object> extensions
) {}
Using exchange():
private Optional<ProductView> handleFindProduct(ClientHttpResponse response) throws IOException {
int status = response.getStatusCode().value();
if (status == 200) {
return Optional.of(json.readValue(response.getBody(), ProductView.class));
}
if (status == 404) {
return Optional.empty();
}
ProblemDetails problem = readProblem(response);
throw switch (status) {
case 409 -> new RemoteConflictException(problem.title(), problem.detail());
case 422 -> new RemoteValidationException(problem.title(), problem.extensions());
case 429 -> new RemoteRateLimitedException(problem.title());
case 503 -> new RemoteUnavailableException(problem.title());
default -> new RemoteHttpException(status, problem.title());
};
}
Important distinction:
Problem Details is for structured error representation.
It is not a retry policy by itself.
Retry should still consider:
method
operation semantics
idempotency key
status
Retry-After
attempt count
remaining deadline
13. Typed Result vs Exception
For service clients, choose deliberately between typed results and exceptions.
Exception style:
ProductView product = catalog.findProductOrThrow(productId, context);
Typed result style:
FindProductResult result = catalog.findProduct(productId, context);
Example typed result:
public sealed interface FindProductResult {
record Found(ProductView product) implements FindProductResult {}
record NotFound(String productId) implements FindProductResult {}
record Unavailable(String dependency, boolean retryable) implements FindProductResult {}
}
Use typed results when:
- absence/conflict is a normal business outcome;
- caller needs explicit branching;
- you want fewer exceptions for expected outcomes;
- workflow state transitions depend on response classification.
Use exceptions when:
- dependency failure is exceptional;
- invalid protocol response should abort;
- caller cannot reasonably recover locally;
- infrastructure boundary failed.
Do not use exceptions for normal 404 if 404 is expected.
Do not use Optional.empty() for dependency failure.
14. Example: Catalog Gateway
Interface:
public interface CatalogGateway {
FindProductResult findProduct(String productId, RequestContext context);
}
Implementation:
@Component
public final class RestClientCatalogGateway implements CatalogGateway {
private final RestClient catalog;
private final ObjectMapper json;
public RestClientCatalogGateway(
@Qualifier("catalogRestClient") RestClient catalog,
ObjectMapper json
) {
this.catalog = catalog;
this.json = json;
}
@Override
public FindProductResult findProduct(String productId, RequestContext context) {
return applyContext(catalog.get().uri("/v1/products/{id}", productId), context)
.exchange((request, response) -> mapFindProduct(productId, response));
}
private FindProductResult mapFindProduct(String productId, ClientHttpResponse response) throws IOException {
int status = response.getStatusCode().value();
if (status == 200) {
ProductView product = json.readValue(response.getBody(), ProductView.class);
return new FindProductResult.Found(product);
}
if (status == 404) {
return new FindProductResult.NotFound(productId);
}
if (status == 429 || status == 503) {
return new FindProductResult.Unavailable("catalog", true);
}
if (status >= 500) {
return new FindProductResult.Unavailable("catalog", true);
}
ProblemDetails problem = tryReadProblem(response);
throw new DownstreamProtocolException("unexpected catalog response " + status + ": " + problem.title());
}
}
This gives the domain a meaningful outcome.
It does not leak Spring ClientHttpResponse or HTTP status interpretation to the use case.
15. Example: Command with Idempotency Key
Command interface:
public interface InventoryGateway {
ReserveInventoryResult reserve(ReserveInventoryCommand command, RequestContext context);
}
Implementation:
@Component
public final class RestClientInventoryGateway implements InventoryGateway {
private final RestClient inventory;
public RestClientInventoryGateway(@Qualifier("inventoryRestClient") RestClient inventory) {
this.inventory = inventory;
}
@Override
public ReserveInventoryResult reserve(ReserveInventoryCommand command, RequestContext context) {
String idempotencyKey = context.idempotencyKey()
.orElseThrow(() -> new IllegalArgumentException("idempotency key required for reserve inventory"));
return applyContext(
inventory.post()
.uri("/v1/reservations")
.header("Idempotency-Key", idempotencyKey)
.contentType(MediaType.APPLICATION_JSON)
.body(command),
context
)
.exchange((request, response) -> mapReserveInventory(response));
}
private ReserveInventoryResult mapReserveInventory(ClientHttpResponse response) throws IOException {
int status = response.getStatusCode().value();
return switch (status) {
case 201 -> new ReserveInventoryResult.Reserved(readReservation(response));
case 202 -> new ReserveInventoryResult.Accepted(readOperation(response));
case 409 -> new ReserveInventoryResult.InsufficientStock(readProblem(response));
case 422 -> new ReserveInventoryResult.Rejected(readProblem(response));
case 429, 503 -> new ReserveInventoryResult.TemporarilyUnavailable();
default -> throw new DownstreamProtocolException("unexpected inventory status: " + status);
};
}
}
Note the invariant:
A retriable side-effecting command requires an idempotency key.
Even if this method does not retry today, designing the client this way prevents unsafe future retries.
16. Timeouts in RestClient
RestClient itself is a facade. Timeout behavior depends on the request factory and underlying HTTP library.
You need at least:
connect timeout
read/response timeout
end-to-end request deadline
retry budget
Typical shape:
@Bean
RestClient inventoryRestClient(InventoryClientProperties properties) {
HttpClient jdkClient = HttpClient.newBuilder()
.connectTimeout(properties.connectTimeout())
.followRedirects(HttpClient.Redirect.NEVER)
.version(HttpClient.Version.HTTP_2)
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(jdkClient);
factory.setReadTimeout(properties.readTimeout());
return RestClient.builder()
.baseUrl(properties.baseUrl().toString())
.requestFactory(factory)
.build();
}
Then pass deadline context:
long remainingMillis = Duration.between(Instant.now(), context.deadline()).toMillis();
if (remainingMillis <= 0) {
throw new DeadlineExceededException("no time left before inventory call");
}
Do not let a downstream call start if the caller deadline has already expired.
17. Retry with RestClient
Do not put retry blindly around every RestClient call.
Bad:
@Retryable
public Reservation reserve(...) {
return inventory.post().uri("/v1/reservations").body(command).retrieve().body(Reservation.class);
}
Why bad?
Because the annotation does not automatically know whether the operation is safe, whether an idempotency key exists, how much deadline remains, or whether retrying will amplify overload.
Better:
public ReserveInventoryResult reserve(ReserveInventoryCommand command, RequestContext context) {
RetryPolicy policy = retryPolicy.forOperation("inventory.reserve");
return retryExecutor.execute(
() -> doReserve(command, context),
RetryMetadata.builder()
.operation("inventory.reserve")
.method("POST")
.idempotencyKeyPresent(context.idempotencyKey().isPresent())
.deadline(context.deadline())
.build()
);
}
Retry decisions should consider:
429 with Retry-After
503 overload
502/504 dependency gateway errors
I/O failure before response
request timeout with idempotency key
remaining deadline
attempt count
backoff with jitter
And should reject:
400 validation failure
401/403 security failure
404 expected absence
409 domain conflict unless operation contract says retry
422 semantic validation failure
POST timeout without idempotency key
18. Circuit Breaker and Bulkhead Placement
RestClient does not define a circuit breaker.
Use a resilience library such as Resilience4j around the service-client operation, not around random low-level fragments.
Good boundary:
CatalogGateway.findProduct
InventoryGateway.reserve
PaymentGateway.authorize
Not:
objectMapper.readValue
RestClient.retrieve only
URI construction only
Example shape:
public FindProductResult findProduct(String productId, RequestContext context) {
return circuitBreaker.executeSupplier(
() -> bulkhead.executeSupplier(
() -> doFindProduct(productId, context)
)
);
}
Layering:
caller deadline
↓
bulkhead
↓
circuit breaker
↓
retry policy, if allowed
↓
RestClient call
↓
response mapping
Some teams place retry inside circuit breaker. Some place retry outside. The important part is to understand what the breaker measures:
per attempt failures
or final operation failures after retries
Be explicit. Otherwise dashboards lie.
19. Observability
A RestClient client should emit telemetry at the dependency operation level.
Recommended attributes:
dependency.name = catalog
operation.name = find_product
http.request.method = GET
http.route = /v1/products/{id}
server.address = catalog.internal
http.response.status_code = 200
error.type = timeout | io_error | remote_5xx | protocol_error
retry.attempt = 1
Do not use raw URL as a metric label:
/v1/products/sku-884928394
Use route template:
/v1/products/{id}
Interceptor skeleton:
public final class HttpClientObservationInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(
HttpRequest request,
byte[] body,
ClientHttpRequestExecution execution
) throws IOException {
long started = System.nanoTime();
int status = 0;
String errorType = "none";
try {
ClientHttpResponse response = execution.execute(request, body);
status = response.getStatusCode().value();
return response;
} catch (SocketTimeoutException e) {
errorType = "timeout";
throw e;
} catch (IOException e) {
errorType = "io_error";
throw e;
} finally {
long elapsedNanos = System.nanoTime() - started;
record(request, status, errorType, elapsedNanos);
}
}
}
In real code, integrate with Micrometer Observation or OpenTelemetry instrumentation rather than inventing a parallel telemetry universe.
But the policy remains:
low-cardinality operation labels
consistent dependency name
consistent outcome taxonomy
trace context propagation
safe logs
20. Logging
Log failures with structured fields.
Good:
{
"event": "downstream_http_failure",
"dependency": "inventory",
"operation": "reserve",
"method": "POST",
"route": "/v1/reservations",
"status": 503,
"outcome": "overloaded",
"attempt": 1,
"elapsed_ms": 682,
"correlation_id": "...",
"trace_id": "..."
}
Bad:
Failed to call https://inventory.internal/v1/reservations with body { ...full order... }
Never log by default:
- full authorization header;
- cookies;
- full request body;
- full response body;
- raw PII;
- large error payloads;
- full
baggageheader; - secrets embedded in query params.
If a downstream error body is needed for debugging, log a bounded hash or redacted summary.
21. Message Conversion
RestClient uses Spring HTTP message conversion to map objects to/from HTTP bodies.
This is convenient:
.body(command)
and:
.body(ProductView.class)
But convenience creates assumptions:
Which ObjectMapper is used?
Are Java time types configured consistently?
Are unknown fields allowed?
Are enum changes backward-compatible?
What content type is sent?
What content type is accepted?
For internal service clients, configure JSON deliberately.
Recommended:
RestClient.builder()
.messageConverters(converters -> {
// Prefer using your application's configured MappingJackson2HttpMessageConverter.
// Do not create a random ObjectMapper with different settings unless isolated intentionally.
})
Operational invariant:
The client and server must agree on JSON compatibility rules.
That does not mean both must deploy together. It means schema evolution must be designed.
22. API Versioning with RestClient
If your internal APIs use header-based versioning:
RestClient catalog = RestClient.builder()
.baseUrl(properties.baseUrl().toString())
.defaultHeader("API-Version", "2026-07-01")
.build();
If they use path versioning:
catalog.get()
.uri("/v1/products/{id}", productId)
Either is workable. The client invariant is the same:
Version selection belongs in the service-client boundary.
Do not let random call sites choose versions independently.
23. Handling 204, Empty Body, and Optional Body
Many client bugs come from assuming every successful response has a JSON body.
Examples:
204 No Content
202 Accepted with operation reference
201 Created with body
200 OK with body
200 OK with empty array
404 Not Found with problem body
Do not do this blindly:
MyResponse response = client.post()
.uri("/v1/commands")
.body(command)
.retrieve()
.body(MyResponse.class);
For command APIs, model status-specific outcomes:
return client.post()
.uri("/v1/commands")
.body(command)
.exchange((request, response) -> switch (response.getStatusCode().value()) {
case 202 -> new CommandResult.Accepted(readOperationRef(response));
case 204 -> new CommandResult.CompletedWithoutBody();
case 409 -> new CommandResult.Conflict(readProblem(response));
default -> throw unexpected(response);
});
Success is not one thing.
It is a contract.
24. Large Response Policy
Spring makes body mapping easy. That does not mean every body should be read into memory.
For normal internal JSON APIs, set a policy:
maximum JSON response size
maximum error body size
pagination required above threshold
stream/file APIs for large payloads
gateway max body limit
client max body limit
If an endpoint can return 100 MB of JSON, the problem is usually API shape, not RestClient.
For large data transfer, use a different pattern:
paged API
object storage handoff
streaming endpoint
async export job
message/event workflow
Do not hide bulk data movement behind a synchronous JSON RestClient call.
25. Testing RestClient Code
Spring's own testing guidance recommends mock web servers for more complete testing of transport behavior, because different HTTP clients can behave differently at the network I/O layer.
Use:
WireMock
OkHttp MockWebServer
MockServer
Spring MockRestServiceServer for isolated Spring-level tests
Test both request and response behavior.
Request assertions:
method is correct
path template is correct
query params are encoded
content type is correct
accept header is correct
trace/correlation headers are present
idempotency key is present for commands
authorization header is scoped correctly
Response assertions:
200 maps to typed success
201 maps to created outcome
202 maps to accepted outcome
204 maps to no-body success
404 maps to expected absence when appropriate
409 maps to domain conflict
422 maps to semantic rejection
429 maps to rate limited
503 maps to unavailable
malformed JSON maps to protocol exception
slow response maps to timeout
connection reset maps to transport exception
Example with pseudo mock server:
@Test
void findProductReturnsNotFoundOn404() {
server.enqueue(problemResponse(404, "https://errors.example/not-found", "Product not found"));
FindProductResult result = catalogGateway.findProduct("sku-123", context);
assertThat(result).isInstanceOf(FindProductResult.NotFound.class);
RecordedRequest request = server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/v1/products/sku-123");
assertThat(request.getHeader("traceparent")).isNotBlank();
}
Do not only test the happy path.
The value of a service client is mostly in failure behavior.
26. Common Anti-Patterns
Anti-pattern 1: RestClient as a global utility
@Component
class HttpUtil {
RestClient rest = RestClient.create();
}
This guarantees policy drift.
Anti-pattern 2: Raw retrieve().body() everywhere
client.get().uri(url).retrieve().body(Foo.class);
This hides status semantics.
Anti-pattern 3: No dependency-specific timeout
One timeout for every dependency is almost always wrong.
Catalog query, payment authorization, fraud scoring, and PDF rendering do not have the same latency budget.
Anti-pattern 4: Retry annotation on unsafe command
@Retryable
public PaymentResult charge(...) { ... }
without idempotency key and retry classification.
This is how duplicate side effects happen.
Anti-pattern 5: Interceptor does too much
An interceptor should not become an invisible workflow engine.
If it changes business behavior, it should not be hidden as transport middleware.
Anti-pattern 6: Domain code catches Spring HTTP exceptions
catch (HttpClientErrorException.NotFound e) {
...
}
This couples domain logic to HTTP client implementation.
Map errors inside the gateway.
27. Production RestClient Template
A useful template:
@Configuration
@EnableConfigurationProperties(CatalogClientProperties.class)
class CatalogClientConfiguration {
@Bean
RestClient catalogRestClient(
CatalogClientProperties properties,
OutboundContextInterceptor contextInterceptor,
HttpClientObservationInterceptor observationInterceptor
) {
HttpClient jdkClient = HttpClient.newBuilder()
.connectTimeout(properties.connectTimeout())
.version(HttpClient.Version.HTTP_2)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(jdkClient);
factory.setReadTimeout(properties.readTimeout());
return RestClient.builder()
.baseUrl(properties.baseUrl().toString())
.requestFactory(factory)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("X-Client-Name", "order-service")
.requestInterceptor(contextInterceptor)
.requestInterceptor(observationInterceptor)
.defaultStatusHandler(
status -> status.value() == 500 || status.value() == 502 || status.value() == 504,
(request, response) -> {
throw new DownstreamFailureException("catalog returned " + response.getStatusCode());
}
)
.defaultStatusHandler(
status -> status.value() == 503,
(request, response) -> {
throw new DownstreamUnavailableException("catalog unavailable");
}
)
.build();
}
}
Then operation-specific client:
@Component
public final class RestClientCatalogGateway implements CatalogGateway {
private final RestClient catalog;
public RestClientCatalogGateway(@Qualifier("catalogRestClient") RestClient catalog) {
this.catalog = catalog;
}
@Override
public FindProductResult findProduct(String productId, RequestContext context) {
ensureDeadlineRemaining(context);
return applyContext(catalog.get().uri("/v1/products/{id}", productId), context)
.exchange((request, response) -> mapFindProduct(productId, response));
}
}
This separation keeps global client policy and operation-specific semantics in the right places.
28. Review Checklist
Before accepting a RestClient implementation, check:
[ ] There is one named RestClient bean per dependency/policy group
[ ] Base URL comes from validated config
[ ] Underlying request factory is explicit
[ ] Connect timeout is explicit
[ ] Read/response timeout is explicit
[ ] Caller deadline is checked before call
[ ] Header propagation is allowlisted
[ ] Trace/correlation propagation is consistent
[ ] Idempotency key is required for retriable commands
[ ] retrieve() is only used where default status behavior is safe
[ ] exchange() is used for operation-specific outcomes
[ ] Problem Details is parsed consistently
[ ] Domain code does not catch Spring HTTP exceptions
[ ] Metrics use dependency/operation/route labels, not raw URL
[ ] Logs are structured and redacted
[ ] Retry is explicit and semantically safe
[ ] Circuit breaker/bulkhead wraps the service-client operation
[ ] Tests use a mock HTTP server for transport behavior
[ ] Tests cover failure cases, not only 200 OK
29. Mental Model Summary
RestClient is the right default for many Spring applications that want synchronous HTTP.
But the production mental model is:
RestClient is not the gateway.
RestClient is the HTTP execution facade inside the gateway.
The gateway owns:
operation semantics
status mapping
error mapping
idempotency requirements
retry safety
deadline handling
observability labels
request context propagation
RestClient helps with:
fluent request construction
message conversion
interceptors
status handlers
underlying transport integration
Spring configuration style
A weak engineer writes:
restClient.get().uri(url).retrieve().body(Foo.class)
A strong engineer asks:
What is the dependency contract?
What are the allowed outcomes?
What are the failure semantics?
What must be observable?
What can be retried?
What must never be retried?
Where is this policy enforced?
Then they use RestClient to enforce those answers consistently.
References
- Spring Framework Reference — REST Clients: https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
- Spring Framework API —
RestClient: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/RestClient.html - Spring Framework API —
RestClient.Builder: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/RestClient.Builder.html - Spring Framework Reference — Testing Client Applications: https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-client.html
- Spring Boot Reference — Calling REST Services: https://docs.spring.io/spring-boot/reference/io/rest-client.html
- Oracle Java SE 25 API —
java.net.http.HttpClient: https://docs.oracle.com/en/java/javase/25/docs/api/java.net.http/java/net/http/HttpClient.html - RFC 9110 — HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110.html
- RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457.html
You just completed lesson 20 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.