This happens a lot. A service calls another API with RestClient, does retrieve().body(...), and moves on. The API answers 422 with a field-error JSON, something like {"field":"email","message":"must not be blank"}. The call throws. A generic catch logs "request failed", and the error lands in the same bucket as timeouts and refused connections. Sometimes there's a retry on top. Retrying a 422 just gives the API another chance to say, very patiently, that the email is blank.
The body never left the HTTP response. It's missing from the return value of retrieve().body(), because on a 4xx that method doesn't return anything. It throws. The JSON is still sitting inside the exception, and nobody looks there.
Below, all three official paths run against a local server: retrieve(), which throws; exchange(), which hands you status and body with no default handler; and onStatus, which turns the 422 into a domain type. No Docker, no Testcontainers, no WireMock. Just the JDK's HttpServer and RestClient.
Versions used
The experiment ran on 3 October 2026 against Spring Boot 4.1.1, the newest GA in the 4.1 line. On Maven Central, the latest/release tag of spring-boot-starter-parent points to 4.2.0-M2. That's a milestone, so we didn't use it. The Boot 4.1.1 BOM brings Spring Framework 7.0.9, and RestClient comes from there, in spring-web. The runtime was Temurin 25.0.4 with Maven 3.9.12. The Spring Boot system requirements page says 4.1.1 requires Java 17 and works up to Java 26, so JDK 25 is covered.
The POM has the 4.1.1 parent, java.version 25, and one dependency:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<groupId>academy.devdojo</groupId>
<artifactId>restclient-4xx</artifactId>
<version>1.0.0</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
</dependencies>
exec-maven-plugin 3.6.4 runs academy.devdojo.restclient4xx.RestClient4xxMain. The project has no Tomcat and no SpringApplication. It's a single main that starts the server, builds the client and calls the API.
The fixture: a JDK HttpServer that answers 422
The JDK 25 HttpServer lives in the jdk.httpserver module, so you don't need a Maven dependency for it. It listens on 127.0.0.1 on an ephemeral port. POST /orders returns 422 with the field JSON. GET /health returns 200 with {"ok":true} as a control:
byte[] errorJson = "{\"field\":\"email\",\"message\":\"must not be blank\"}"
.getBytes(StandardCharsets.UTF_8);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/orders", exchange -> {
try {
exchange.getRequestBody().readAllBytes();
if (!"POST".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(422, errorJson.length);
exchange.getResponseBody().write(errorJson);
} finally {
exchange.close();
}
});
// /health: mesmo formato, 200 com {"ok":true}
server.start();
The client is as plain as it gets:
String base = "http://127.0.0.1:" + server.getAddress().getPort();
RestClient client = RestClient.create(base);
RestClient.create(baseUrl) skips Boot's auto-configuration entirely. According to the Boot RestClient documentation, no RestClientCustomizer is applied in that case. Here that helps: what you see is the Framework's default behavior, with nothing configured on top.
These are the commands that ran:
mvn -q -B -e package
mvn -B -e exec:java
Exit 0, BUILD SUCCESS. Here's the full output:
HEALTH body={"ok":true}
RETRIEVE class=UnprocessableContent status=422 message=422 Unprocessable Content: "{"field":"email","message":"must not be blank"}" body={"field":"email","message":"must not be blank"}
EXCHANGE status=422 body={"field":"email","message":"must not be blank"}
ONSTATUS type=FieldValidationException field=email message=must not be blank
The next sections go through it line by line.
retrieve(): the exception already carries the JSON
The Spring Framework REST clients documentation puts it plainly:
> "By default, RestClient throws a subclass of RestClientException when retrieving a response with a 4xx or 5xx status code. This behavior can be overridden using onStatus."
The ResponseSpec javadoc says the same thing about body(Class): by default it throws RestClientResponseException on a 4xx or 5xx status. The code:
try {
client.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body("{}")
.retrieve()
.body(String.class);
System.out.println("RETRIEVE unexpected-success");
} catch (RestClientResponseException ex) {
System.out.println("RETRIEVE class=" + ex.getClass().getSimpleName()
+ " status=" + ex.getStatusCode().value()
+ " message=" + ex.getMessage()
+ " body=" + ex.getResponseBodyAsString());
}
The RETRIEVE line tells you three things.
First, the class. A 4xx becomes an HttpClientErrorException, and a 422 in particular becomes the nested type UnprocessableContent. This matters if you catch the exact type, because HttpClientErrorException.UnprocessableEntity has been @Deprecated since 7.0 in favor of UnprocessableContent. Catching RestClientResponseException means you don't depend on that name at all.
Second, the body. getResponseBodyAsString() returns the JSON exactly as the server sent it. The class also has getResponseBodyAs(Class), which converts the error content into a type.
Third, getMessage(). In this run it already included the whole JSON: 422 Unprocessable Content: "{"field":"email",...}". So even the laziest log line, the kind that only prints the exception message, would have shown which field was wrong. "request failed" still manages to throw that away.
What retrieve() won't do is return the error DTO. On a 422, retrieve().body(FieldError.class) never hands back a FieldError, because the default handler throws before any happy-path conversion runs. Code written in the hope of getting an error object back will keep hoping.
exchange(): status and body without the default handler
exchange() gives you the raw response. The same docs page explains that status handlers aren't applied there, "because the exchange function already provides access to the full response".
client.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body("{}")
.exchange((request, response) -> {
String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
System.out.println("EXCHANGE status=" + response.getStatusCode().value()
+ " body=" + body);
return null;
});
There's no try/catch, and nothing was thrown. The EXCHANGE status=422 body={...} line shows the status and the JSON read straight off the response.
You get control, and you also get the responsibility that comes with it. With no default handler, nothing throws for you on a 4xx or a 5xx. If you use exchange(), you decide what happens for each status range. The fixture's return null is only there so it can print. In a real client the function returns something that represents either success or a field error, or it throws your own type.
onStatus: the 422 becomes a domain type
onStatus keeps retrieve() for the happy path and intercepts the statuses you pick. In Framework 7.0.9 the signature is onStatus(Predicate<HttpStatusCode>, ErrorHandler). There's also onStatus(ResponseErrorHandler), but no overload takes an HttpStatusCode directly. You select the status with a predicate.
The domain type is small:
record FieldError(String field, String message) {}
static final class FieldValidationException extends RuntimeException {
private final FieldError error;
FieldValidationException(FieldError error) {
super(error.field() + ": " + error.message());
this.error = error;
}
FieldError error() {
return error;
}
}
The call:
try {
client.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body("{}")
.retrieve()
.onStatus(status -> status.value() == 422, (request, response) -> {
String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
if (!body.contains("\"field\":\"email\"")) {
throw new IllegalStateException("unexpected 422 body: " + body);
}
throw new FieldValidationException(new FieldError("email", "must not be blank"));
})
.body(String.class);
System.out.println("ONSTATUS unexpected-success");
} catch (FieldValidationException ex) {
System.out.println("ONSTATUS type=" + ex.getClass().getSimpleName()
+ " field=" + ex.error().field()
+ " message=" + ex.error().message());
}
Result: ONSTATUS type=FieldValidationException field=email message=must not be blank. Code that calls the client never sees a RestClientResponseException. It sees a validation error with a field and a message, which it can turn into a user-facing response, a metric or a business decision.
One note about the fixture: the handler reads the body, checks that it's the expected JSON, and builds the FieldError from known values. That keeps the example free of any mapper setup. In a real client, the handler deserializes response.getBody() with the Jackson that the starter already brings and passes the object to the exception.
The three paths side by side
| Path | What happens on 422 | Where the JSON is | When to use it |
|---|---|---|---|
retrieve().body(...) | Throws UnprocessableContent (a subtype of RestClientResponseException) | getResponseBodyAsString() / getResponseBodyAs(Class) on the exception | An unexpected 4xx that should fail loudly, with status and body in the log |
retrieve().onStatus(...) | Your handler runs and throws the domain type | response.getBody() inside the handler | An API with a known error contract, like a 422 carrying a field error |
exchange(...) | Nothing is thrown; status and body arrive raw | response.getBody() in the function | When success and error have to be handled together and you want full control |
The trap: "request failed" and nothing else
RestClient is rarely the bug. This catch is:
catch (RestClientException e) {
log.warn("request failed");
}
It lumps everything together: the 422 that tells you exactly which field is wrong, the 503 from a dependency that's down, and the network timeout. They all take the same route, whether that's a retry, a fallback or silence. The information that would have fixed the problem arrived in the body, and the code threw it out.
There are two ways to fix it, depending on where you want the decision to live:
- At the call site: catch
RestClientResponseExceptionbefore any genericRestClientException, and loggetStatusCode().value()together withgetResponseBodyAsString(), the same calls the fixture prints. Transport errors have no HTTP response, so they get their own catch. - In the client: translate the 422 into a domain type with
onStatus. Then callers don't even need to know there's an HTTP exception in between.
To see this in production, count errors by status and by domain type, not by "failed". A rise in FieldValidationException points to a broken contract between services. A rise in 5xx or timeouts points to availability. Putting both in the same counter is how a 422 ends up labeled "instability". Before you send error bodies to the logs, check what's in them: validation JSON sometimes echoes the value the user submitted, and that can be personal data.
Limits of the experiment
This was a single functional run, with one status (422), one JSON shape and a local HttpServer. It isn't a performance measurement, and nothing here says anything about latency or throughput.
The client came from RestClient.create(baseUrl), not from Boot's auto-configured RestClient.Builder. If your project applies a RestClientCustomizer or sets a default status handler on the builder, rerun the test with that builder. getResponseBodyAs(Class) is in the javadoc but wasn't exercised in this run, since the JSON was read as a string. getMessage() included the body in this version, but the message format isn't a contract, so don't parse it. An onStatus handler that doesn't throw wasn't tested either. The fixture's handler always throws.
This article also isn't about slow dependencies. Resilience4j bulkhead on Boot 4.1 isolates a slow dependency with WireMock, but it doesn't cover retrieve/exchange/onStatus or a 4xx JSON body.
Recommendation
DevDojo wouldn't ship a retrieve() against an API that returns 422 with field errors unless there's an onStatus or an exchange() reading that JSON. The default handler is a reasonable way to fail loudly when something is genuinely unexpected. But once an API documents an error format, that format is part of the contract, and the client should turn it into a type the rest of the code understands.
The default behavior is fine when that API only returns a 4xx because of a programming bug and there's nothing different to do about it. Then all the catch needs to do is log the status and the body. If the team starts seeing retries on 4xx, "instability" alerts that turn out to be validation, or a RestClientException catch that never reads the body, it's time to revisit the client.
Next step
Pick the client that talks to the API returning 422 and add an onStatus(status -> status.value() == 422, ...) that throws your domain type. If that call needs to handle success and error together, switch it to exchange() instead. Then write a local test shaped like the fixture: an HttpServer on an ephemeral port returning the API's real JSON, plus an assertion that field comes through as email in your type. It doesn't need Docker and it runs in seconds.