Spring Boot 4 HTTP Service Clients: @HttpExchange Interfaces with RestClient (Complete Guide)
A complete guide to the Spring Boot HTTP Service Client on Boot 4.1.2 and Spring Framework 7.0.10: @HttpExchange interfaces on RestClient, @ImportHttpServices groups configured with spring.http.serviceclient.*, error handling with defaultStatusHandler and ProblemDetail, retries with Framework 7 @Retryable, bearer, API key and OAuth2 client-credentials auth, testing with MockRestServiceServer and a real local server, and an OpenFeign comparison and migration. Every output was captured from a runnable module, and corrections to the first version are listed.
I counted the boilerplate in a Spring Boot 3 service last month: four external API clients, each with its own RestClient bean, its own base-URL configuration, its own error-handling wrapper, and its own request-building methods. Around 300 lines of plumbing across four files, none of which contained a single line of business logic.
A Spring Boot HTTP Service Client removes nearly all of it. You write a plain Java interface, annotate its methods with @HttpExchange, and Spring generates the implementation at runtime on top of RestClient. Think of it as Spring Data repositories, but for REST APIs — and with less ceremony than OpenFeign ever had. This guide is the full tour: the interface, registration with @ImportHttpServices, configuration, and then the parts the short tutorials skip — error handling, retries, authentication, testing and migrating from OpenFeign.
Every behaviour below was run against a small local stub server (no internet needed), and the output you see is copied from files in the companion module. In the process I found that several statements in the first version of this post were wrong; they are corrected in place and listed in the callout below.
Versions. Everything ran on Spring Boot 4.1.2, Spring Framework 7.0.10, Spring Security 7.1.2 and JDK 25.0.5 (Temurin, LTS). Those are the latest GA releases in maven-metadata.xml on 6 November 2026; 4.2.0-RC2 exists but is a release candidate, not GA. Boot 4.0.0 reached Maven Central on 20 November 2025 and 4.1.0 on 10 June 2026. The first version of this post was tested on Boot 4.0.5, Framework 7.0.2 and Java 21.0.3. The whole module is here: http-service-clients.
Corrections to the first version of this post. (1) The group properties are spring.http.serviceclient.<group>.* with global defaults under spring.http.clients.*; the keys the old post showed (spring.http.client.service.group.*) are silently ignored. (2) A missing base URL does not cause a NullPointerException; it fails with IllegalArgumentException: URI with undefined scheme. (3) @RestClientTest lives in org.springframework.boot.restclient.test.autoconfigure, and @RestClientTest(PostService.class) does not make the interface a bean. (4) Optional<Post> does not turn a 404 into an empty result by default. (5) A wrong return type surfaces as RestClientException wrapping Jackson 3’s tools.jackson...MismatchedInputException. (6) @ClientRegistrationId only does anything once you register an OAuth2RestClientHttpServiceGroupConfigurer; Boot does not. (7) Spring Cloud load-balancing and circuit-breaker group configurers exist, but I only confirmed the classes, not that they apply “automatically”. Details and evidence are in the sections below.
What a Spring Boot HTTP Service Client actually is
An HTTP Service Client is a Java interface whose methods are annotated with @HttpExchange (or its shortcut variants @GetExchange, @PostExchange and friends). At startup Spring generates a proxy for the interface that turns each method call into an HTTP request using RestClient (or WebClient) underneath.
The idea arrived in Spring Framework 6 and Boot 3.2, but you had to build an HttpServiceProxyFactory by hand, create each proxy as a bean and configure the HTTP client yourself. Spring Framework 7 and Boot 4 added the HTTP Service Registry: @ImportHttpServices registers proxies for you, and application.yml configures them. None of that exists in Boot 3.x.
The diagram is the mental model for the rest of the article. The proxy only describes a request; everything that makes it succeed or fail in production — timeouts, auth headers, retries, error mapping — lives in the RestClient box, and you configure it per group.
What you need: the right starter and a Java 17+ baseline
You need Spring Boot 4.0 or later (which brings Spring Framework 7) and Java 17 or later; the module here uses 25. The first version of this post said “no extra dependencies, spring-boot-starter-web is enough”. In Boot 4 the HTTP-client auto-configuration moved into its own modules, and the group support (HttpServiceClientAutoConfiguration, the spring.http.serviceclient binding) sits in spring-boot-restclient and spring-boot-http-client, which spring-boot-starter-restclient brings in. I did not test a build without that starter beyond listing the jar contents, so treat “you need it” as the safe reading rather than a measured failure; the sibling Basic Auth post reports the same starter split. The module’s pom.xml (‘ + link(‘pom.xml’) + ‘):
The @HttpExchange annotations and the registry ship in spring-web; the starter supplies the auto-configuration around them.
Start here: the data model you are mapping
The original post used the JSONPlaceholder API. It still serves as the shape of the data, but the code here talks to a local stub (StubServer.java) so it runs offline and the output is reproducible. The response model is a record (Post.java):
package com.ankurm.hsc.model;
public record Post(Integer id, Integer userId, String title, String body) {
}
Records are ideal here — immutable, free equals()/hashCode()/toString(), and Jackson (the JSON library in Spring Boot) can deserialise into them without extra annotations.
The interface that does all the work
Define an interface and annotate its methods to describe the endpoints (PostClient.java):
@HttpExchange("/posts")
public interface PostClient {
@GetExchange
List<Post> findAll();
@GetExchange
List<Post> findByUser(@RequestParam Integer userId);
@GetExchange("/{id}")
Post findById(@PathVariable Integer id);
@GetExchange("/{id}")
ResponseEntity<Post> findEntity(@PathVariable Integer id);
/** Used only to show that Optional does NOT turn a 404 into Optional.empty(). */
@GetExchange("/{id}")
Optional<Post> findOptional(@PathVariable Integer id);
@PostExchange
Post create(@RequestBody Post post);
@PutExchange("/{id}")
Post update(@PathVariable Integer id, @RequestBody Post post);
@DeleteExchange("/{id}")
void delete(@PathVariable Integer id);
}
How the interface works
1. The class-level @HttpExchange("/posts") sets the base path for every method. Method URLs are relative to it, and it is in turn relative to the group’s base URL configured later.
2. @GetExchange, @PostExchange, @PutExchange, @DeleteExchange (and @PatchExchange) are shortcuts that combine @HttpExchange with an HTTP method. They mirror the @GetMapping/@PostMapping naming you know from @RestController.
3. @PathVariable and @RequestBody work exactly like their controller counterparts. @RequestParam adds query parameters and @RequestHeader custom headers.
4. The return type tells Spring what to deserialise into. Return ResponseEntity<Post> if you need the status code and headers.
Registering the interface: one annotation, no factory boilerplate
In Boot 4 you register interfaces with @ImportHttpServices, which generates the proxy and registers it as a bean (HttpClientsConfig.java, trimmed to the groups relevant here):
The group attribute is the key concept. A group is a set of interfaces that share one HTTP client configuration: the same base URL, timeouts and headers. Three external APIs means three groups. I confirmed with javap that @ImportHttpServices offers value, types, group, basePackageClasses, basePackages and clientType, and the test prints what the registry knows:
# groups the HttpServiceProxyRegistry knows about
group apikey -> [ApiKeyClient]
group bearer -> [SecureClient]
group flaky -> [FlakyClient]
group github -> [GitHubClient]
group oauth -> [OAuthClient]
group posts -> [ParamsClient, PostClient]
group resilient -> [ResilientClient]
PostClient bean is a JDK proxy: true
registry.getClient("posts", PostClient.class) is the injected bean: true
You can also register by package scan instead of listing interfaces one by one. Compiled, not run (Download hsc-BasePackageScan.java):
@ImportHttpServices(
group = "jsonplaceholder",
basePackages = "com.example.demo.client"
)
@Configuration(proxyBeanMethods = false)
public class HttpClientConfig {
}
Base URL, timeouts and per-group config — all in application.yml
Boot auto-configures the HTTP client for each group; you give it the base URL and any timeouts (application.yml):
spring:
http:
clients: # defaults for every group
connect-timeout: 2s
read-timeout: 5s
serviceclient: # one entry per group registered with @ImportHttpServices
posts:
base-url: ${stub.url}
default-header:
X-Client: hsc-demo
github: # a second API with its own base URL; never called in the tests
base-url: https://api.github.com
read-timeout: 5s
flaky:
base-url: ${stub.url}
read-timeout: 1500ms # per-group override of spring.http.clients.read-timeout
resilient:
base-url: ${stub.url}
bearer:
base-url: ${stub.url}
apikey:
base-url: ${stub.url}
default-header:
X-Api-Key: demo-key-123
oauth:
base-url: ${stub.url}
The diagram shows the two layers. Properties set the declarative things (base URL, timeouts, default headers); a configurer bean, covered next, sets anything that needs an object. Before Boot 4 you created a RestClient.Builder, wrapped it in an HttpServiceProxyFactory and wrote a bean method per interface; now it is configuration.
The keys in the first version of this post were wrong, and Boot said nothing. It showed spring.http.client.service.group.jsonplaceholder.base-url and spring.http.client.service.connect-timeout. Boot 4.1.2 binds spring.http.serviceclient.<group>.base-url (a Map of HttpClientProperties, with base-url, default-header, timeouts) and global defaults under spring.http.clients.*. With the old keys the application starts fine and the first call fails. Real run:
# A. the keys the article's first version used (spring.http.client.service.group.<g>.base-url)
findAll() -> java.lang.IllegalArgumentException: URI with undefined scheme
# B. the keys Spring Boot 4.1.2 binds (spring.http.serviceclient.<g>.base-url)
findAll() -> OK -> 3
# C. no base URL at all
findAll() -> java.lang.IllegalArgumentException: URI with undefined scheme
Transcript: 02-properties-and-pitfalls.txt. Case C is also the “missing base-url” mistake: the old post predicted a NullPointerException; it is an IllegalArgumentException.
For reference: the first version’s property block (does not work on Boot 4.1.2)
Kept so you can recognise it in older copies of this snippet. The second block below is the multi-group variant from the same version.
# Base URL for the "jsonplaceholder" group
spring.http.client.service.group.jsonplaceholder.base-url=https://jsonplaceholder.typicode.com
# Global timeout (applies to all groups)
spring.http.client.service.connect-timeout=5s
spring.http.client.service.read-timeout=10s
# Per-group timeout override
spring.http.client.service.group.jsonplaceholder.read-timeout=3s
# JSONPlaceholder group
spring.http.client.service.group.jsonplaceholder.base-url=https://jsonplaceholder.typicode.com
# GitHub group
spring.http.client.service.group.github.base-url=https://api.github.com
spring.http.client.service.group.github.read-timeout=5s
The working equivalents are in application.yml above (application.yml).
Injecting and using the client — it is just a bean
Inject the interface like any bean. The five calls from the original runner, now against the stub, are in Step01RegistryAndCrudTest.java; the original runner itself is here: Download hsc-DemoRunner.java (compiled against the module, not executed). Real output of the CRUD test:
# CRUD against the stub (every call is a real HTTP request)
findAll() -> 3 posts
findById(1) -> Post[id=1, userId=1, title=First post, body=Body of the first post]
findByUser(2) -> [3]
findEntity(2) -> status 200 OK, Content-Type application/json
create(...) -> id 101
update(1, ...) -> title 'Updated Title'
delete(1) -> void, no exception
The original runner, as a download
The first version wired the five calls into an ApplicationRunner bean. It compiles against the module (Download hsc-DemoRunner.java) and is shown here for comparison; the transcript above is the executed version.
@Bean
ApplicationRunner runner(PostClient postService) {
return args -> {
// GET all posts
List<Post> allPosts = postService.findAll();
System.out.println("Total posts: " + allPosts.size());
// GET a single post
Post post = postService.findById(1);
System.out.println("Post 1 title: " + post.title());
// POST a new post
Post newPost = new Post(null, 1, "HTTP Service Clients", "Spring Boot 4 is amazing!");
Post created = postService.create(newPost);
System.out.println("Created post ID: " + created.id());
// PUT update
Post updated = postService.update(1, new Post(1, 1, "Updated Title", "Updated body"));
System.out.println("Updated title: " + updated.title());
// DELETE
postService.delete(1);
System.out.println("Deleted post 1");
};
}
Transcript: 01-registry-and-crud.txt. No RestClient, no WebClient, no HttpServiceProxyFactory, no bean methods — just an interface and a few lines of configuration.
Consuming multiple APIs with service groups
Real applications rarely call one API. Add the GitHub API next to the posts API (GitHubClient.java):
Register both groups with repeated @ImportHttpServices on one configuration class (shown in the registration block above), and give each its own base URL in application.yml (posts and github above). The GitHub group is registered but never called in the tests, since they run offline. Interfaces in one group share that group’s client configuration, and proxies for both groups are ordinary beans.
Supported method parameters and return types
@PathVariable fills path placeholders, @RequestParam adds query parameters, @RequestHeader adds headers (useful for correlation ids), @RequestBody serialises an object, a java.net.URI argument replaces the whole URL, and an HttpMethod argument overrides the method. One method per kind, run against the stub (ParamsClient.java):
@HttpExchange
public interface ParamsClient {
@GetExchange("/echo/headers")
Map<String, String> withHeader(@RequestHeader("X-Correlation-Id") String correlationId);
@GetExchange("/posts")
List<Post> optionalParam(@RequestParam(required = false) Integer userId);
@GetExchange("/posts")
List<Post> defaultedParam(@RequestParam(defaultValue = "2") Integer userId);
/** A java.net.URI argument replaces the whole URL, base URL included. */
@GetExchange
Post byUri(URI uri);
}
# argument kinds (ParamsClient)
@RequestHeader -> upstream saw X-Correlation-Id=abc-123
@RequestParam(required=false), null -> 3 posts
@RequestParam(defaultValue="2") -> posts [3]
URI argument -> Second post
Return types
You can return the deserialised object, a List<Post>, a ResponseEntity<Post> for status and headers, Optional<Post>, or void for fire-and-forget calls like DELETE. For reactive groups backed by WebClient you can return Mono and Flux. The Optional case is not what the first version of this post implied, and it is covered under error handling.
Programmatic configuration with RestClientHttpServiceGroupConfigurer
When properties are not enough — a default header, an interceptor, an error handler — register a RestClientHttpServiceGroupConfigurer bean. javap shows its Groups callback offers filterByName, filter, forEachClient, forEachProxyFactory and forEachGroup. The GitHub example from the original post, which compiles and registers (HttpClientsConfig.java):
The configurer receives all groups; you filter by name and customise the RestClient.Builder of each one. It is the same builder API you already know: interceptors, default headers, status handlers, message converters.
When the upstream misbehaves: error handling
Most of the days you lose with HTTP clients are lost here, so this section gets the length. Start with what you get for free (a plain RestClient, no handler). Test: Step03ErrorHandlingTest.java.
# 1. framework defaults (a plain RestClient, no status handler)
findById(99) -> HttpClientErrorException$NotFound
status : 404 NOT_FOUND
message : 404 Not Found: "{"type":"https://stub.example/problems/post-not-found","title":"Post not found","status":404,"detail":"No post with id 99","instance":"/posts/99"}"
content-type: application/problem+json
getResponseBodyAs(ProblemDetail): title='Post not found' detail='No post with id 99' type=https://stub.example/problems/post-not-found
alwaysDown() -> HttpServerErrorException.ServiceUnavailable, status 503 SERVICE_UNAVAILABLE
A 4xx throws HttpClientErrorException (with a subclass per status such as NotFound), a 5xx throws HttpServerErrorException, and both extend RestClientResponseException, which keeps the status, headers and raw body. The message embeds the body, and getResponseBodyAs(ProblemDetail.class) decodes an RFC 9457 body into a typed object — the ProblemDetail post covers the server side of the same format.
Failures with no response at all are different exceptions. The flaky group has a 1500 ms read timeout and the stub’s /slow takes 3000 ms:
# 2. a response that never comes: read timeout is 1500ms on the flaky group, /slow takes 3000ms
slow() -> ResourceAccessException, cause java.net.http.HttpTimeoutException
message: I/O error on GET request for "http://127.0.0.1:<port>/slow": Request cancelled
# 3. nobody listening
findAll() -> ResourceAccessException, cause java.net.ConnectException
Both are ResourceAccessException (I/O errors), not a status exception, which matters when you decide what to retry. Transcript: 03-error-handling.txt.
defaultStatusHandler: your own exceptions, once per group
The usual reason to customise is to stop leaking HttpClientErrorException into your domain. Install a defaultStatusHandler on the group’s builder; it then runs for every method of every interface in the group. This handler decodes the problem body and throws our own types (UpstreamProblemStatusHandler.java):
public void handle(HttpRequest request, ClientHttpResponse response) throws IOException {
int status = response.getStatusCode().value();
ProblemDetail problem = readProblem(response, status);
if (status == 404) {
throw new UpstreamNotFoundException(problem);
}
if (status == 422) {
throw new UpstreamValidationException(problem);
}
if (response.getStatusCode().is5xxServerError()) {
throw new UpstreamUnavailableException(status, problem.getDetail());
}
// anything else (401, 403, 409 ...): fall back to a generic failure carrying the body
throw new IllegalStateException("Unexpected upstream status " + status + ": " + problem.getDetail());
}
RestClientHttpServiceGroupConfigurer postsErrorHandling() {
return groups -> groups.filterByName("posts").forEachClient((group, builder) ->
builder.defaultStatusHandler(
status -> status.isError(), new UpstreamProblemStatusHandler()));
# 6. our own defaultStatusHandler on the "posts" group (UpstreamProblemStatusHandler)
findById(99) -> UpstreamNotFoundException: Upstream says not found: No post with id 99
problem.type=https://stub.example/problems/post-not-found instance=/posts/99
create(blank title) -> UpstreamValidationException: Upstream rejected the request: Validation failed
extension 'errors' = [{field=title, message=must not be blank}]
alwaysDown() -> UpstreamUnavailableException: Upstream unavailable (503): Upstream is down for maintenance
The 422 keeps the errors extension member from the problem body, so callers can see which field was rejected. If you used Feign’s ErrorDecoder this is the equivalent, and as the output shows it can read the body.
Going deeper: 404 as an empty result, Optional and ResponseEntity
With the default handler, Optional<Post> and ResponseEntity<Post> return types still throw on a 404. The first version of this post said Optional was for “endpoints that may return 404”; that is only true if you opt in. spring-web 7.0.10 ships NotFoundRestClientAdapterDecorator, which you pass to the proxy factory builder via exchangeAdapterDecorator(...):
# 4. Optional<Post> and ResponseEntity<Post> on a 404 with the default handler
findOptional(99) -> NotFound
findEntity(99) -> NotFound
# 5. NotFoundRestClientAdapterDecorator (spring-web 7.0.10) turns a 404 into an empty result
findById(99) -> null
findOptional(99) -> Optional.empty
findEntity(99) -> status 404 NOT_FOUND, body null
I used it on a hand-built factory; I did not find a Boot property that enables it for registry groups, so check before assuming. Test: Step03ErrorHandlingTest.java.
Translating upstream errors for your own callers
Alternatively handle them where your own API turns exceptions into responses. The original @RestControllerAdvice, rewritten to the types above, plus a thin controller so it has something to translate (UpstreamExceptionAdvice.java):
# our service (MockMvc, real HTTP to the stub behind it)
GET /api/posts/1 -> 200 {"id":1,"userId":1,"title":"First post","body":"Body of the first post"}
GET /api/posts/99 -> 404 {"detail":"No post with id 99","instance":"/api/posts/99","status":404,"title":"Not Found"}
POST blank title -> 400 {"detail":"Upstream rejected the request","instance":"/api/posts","status":400,"title":"Bad Request","errors":[{"field":"title","message":"must not be blank"}]}
GET /api/down -> 502 {"detail":"Upstream service error","instance":"/api/down","status":502,"title":"Bad Gateway"}
GET /api/slow -> 504 {"detail":"Upstream service timed out","instance":"/api/slow","status":504,"title":"Gateway Timeout"}
Notice the status translation: a 503 from the upstream becomes a 502 from us, and a timeout becomes a 504. Passing the upstream status through unchanged would tell your callers that your service is unavailable. Transcript: 04-controller-advice.txt.
Adding spring-security-oauth2-client secures your own endpoints. The first run of the advice test failed its 200 assertion until I added a permit-all SecurityFilterChain (DemoSecurityConfig.java). The OAuth2 client starter brings Spring Security with Boot’s default “everything needs a login” chain, which applies to your controllers, not just to outgoing calls.
Going deeper: a wrong return type, @RequestMapping on a client interface
Declaring Post when the API returns an array does not give a bare Jackson exception, and @GetMapping on a client interface is not rejected when the proxy is created:
# D. a return type that does not match the JSON (array decoded as a single Post)
list() -> org.springframework.web.client.RestClientException: Error while extracting response for type [com.ankurm.hsc.model.Post] and content type [application/json]
root cause class: tools.jackson.databind.exc.MismatchedInputException
# E. @GetMapping on a client interface instead of @GetExchange
createClient(UsesRequestMapping.class).list() -> java.lang.IllegalStateException: Unexpected method invocation: public abstract java.lang.String com.ankurm.hsc.Step02PropertiesAndPitfallsTest$UsesRequestMapping.list()
So “using @RequestMapping will not work” is right, but the failure is an IllegalStateException on the first call, not at startup.
Retries: what Spring Framework 7 ships and what it does not
Spring Framework 7 put retry into the core. I checked the jars rather than the docs: spring-core 7.0.10 contains org.springframework.core.retry (RetryTemplate, RetryPolicy, RetryListener, RetryException), and spring-context contains org.springframework.resilience.annotation (@Retryable, @ConcurrencyLimit, @EnableResilientMethods). The API shape, from javap: RetryPolicy.builder() with maxRetries, delay, jitter, multiplier, maxDelay, timeout, includes, excludes, predicate; RetryTemplate.execute(...) throws a checked RetryException and invoke(...) does not. The full behaviour, counted call by call, is in the Spring Framework 7 resilience post; here is how it applies to a client. Test: Step05RetryTest.java.
This diagram is the trap worth learning first. The same word “3” means four calls in one library and three in the other, which the stub confirmed by counting requests. It is a spot to check before copying a number from one library’s config to another.
Option 1: @Retryable on the interface method
Because the proxy is a bean, Spring’s retry advice applies to it. The annotation on the interface works (FlakyClient.java, needs @EnableResilientMethods on a configuration class):
/** Retried by Spring Framework 7's @Retryable (needs @EnableResilientMethods): 3 retries, 20 ms apart. */
@Retryable(includes = HttpServerErrorException.class, maxRetries = 3, delay = 20)
@GetExchange("/flaky/{key}")
Post get(@PathVariable String key, @RequestParam int failures);
# 2. @Retryable on the @HttpExchange interface method (bean from the "flaky" group)
get("ann", failures=2) -> 'Recovered on call 3'; stub saw 3 calls
get("ann-dead", failures=10) -> HttpServerErrorException$ServiceUnavailable / 503 Service Unavailable: "{"type":"https://stub.example/problems/service-unavailable","title":"Service Unavailable","status":503,"detail":"Flaky upstream, call 4","instance":"/flaky/ann-dead"}"
stub saw 4 calls (maxRetries = 3)
Option 2: RetryTemplate around the call
Use it where you want explicit control or are not in a Spring-managed proxy. invoke rethrows the last exception once retries run out; execute wraps them in a RetryException carrying every attempt.
# 1. RetryTemplate around the call (org.springframework.core.retry, spring-core 7.0.10)
get("tpl", failures=2) -> 'Recovered on call 3'
stub saw 3 calls; listener events: [beforeRetry, onRetryFailure(ServiceUnavailable), beforeRetry, onRetrySuccess]
maxRetries(2) against a dead upstream -> HttpServerErrorException$ServiceUnavailable / 503 Service Unavailable: "{"type":"https://stub.example/problems/service-unavailable","title":"Service Unavailable","status":503,"detail":"Flaky upstream, call 3","instance":"/flaky/exhaust"}"
stub saw 3 calls
execute() instead of invoke() -> RetryException, retryCount=2, exceptions=3, last=ServiceUnavailable
Option 3: retry once for the whole group, in an interceptor
If every call in a group should retry, put it in the RestClient’s interceptor chain and no interface needs an annotation. This one retries GET/HEAD/PUT/DELETE/OPTIONS on 5xx and I/O errors and deliberately skips POST and PATCH (RetryingInterceptor.java):
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
if (!isIdempotent(request.getMethod())) {
return execution.execute(request, body);
}
try {
return retryTemplate.execute(() -> {
attempts.incrementAndGet();
ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode().is5xxServerError()) {
int status = response.getStatusCode().value();
response.close();
throw new UpstreamUnavailableException(status, "retrying " + request.getMethod() + " " + request.getURI().getPath());
}
return response;
});
} catch (RetryException e) {
// retries exhausted: surface the last failure, not the wrapper
Throwable last = e.getLastException();
if (last instanceof IOException io) {
throw io;
}
throw (RuntimeException) last;
}
}
# 3. the same retry as a ClientHttpRequestInterceptor on the "resilient" group (idempotent methods only)
get("icpt", failures=2) -> 'Recovered on call 3'; stub saw 3 calls; interceptor attempts so far: 3
POST createUnstable -> HttpServerErrorException$ServiceUnavailable / 503 Service Unavailable: "{"type":"https://stub.example/problems/service-unavailable","title":"Service Unavailable","status":503,"detail":"Created, but the response was lost","instance":"/posts/unstable"}"
stub created the resource 1 time(s): the interceptor did not retry a POST
Never retry a POST blindly. I put @Retryable on a POST whose response is lost after the server has done the work. Real output:
# 4. the trap: @Retryable on a POST whose response was lost after the server did the work
createUnstable() -> id 102, body 'created 2 times so far'
stub created the resource 2 times for ONE logical call
One logical call, two created resources (05-retries.txt). The method in FlakyClient carries a “DO NOT copy this” comment for that reason. Retry non-idempotent calls only if the upstream supports idempotency keys.
Resilience4j as the alternative
Resilience4j 2.4.0 (latest GA from maven-metadata.xml) decorates the same plain client the same way; it is the better fit when you also need circuit breakers, bulkheads or rate limiters, because it has them and Framework 7 does not. Counting is different (attempts include the first call):
# 5. Resilience4j 2.4.0 around the same plain client
maxAttempts(3), failures=2 -> 'Recovered on call 3'; stub saw 3 calls
maxAttempts(3) against a dead upstream -> ServiceUnavailable; stub saw 3 calls
The circuit breaker post covers the rest. Spring Cloud 2025.1 also ships group-aware pieces: I listed LoadBalancerRestClientHttpServiceGroupConfigurer and CircuitBreakerRestClientHttpServiceGroupConfigurer in the spring-cloud-commons 5.0.2 jar, but I did not run them, so I will not claim how they behave. Transcript: 05-retries.txt.
Authentication: bearer tokens, API keys and OAuth2
Auth belongs on the group, not on the interface. The interface says what to call; the group says as whom. Test: Step06AuthTest.java.
Bearer token with a ClientHttpRequestInterceptor
A static or rotating token as an interceptor (BearerTokenInterceptor.java), attached to the bearer group only:
Because HttpClientProperties has a default-header map (javap), a static API key is one line of application.yml — the apikey group in the file above. Real run:
# 1. no credentials (a plain RestClient)
whoAmI() -> 401 Unauthorized, WWW-Authenticate: Bearer realm="stub"
# 2. bearer token added by a ClientHttpRequestInterceptor on the "bearer" group
whoAmI() -> {caller=demo-user, tokenKind=static}
headers the upstream saw: Authorization=Bearer <redacted>, X-Correlation-Id present=true (8 chars)
# 3. API key from spring.http.serviceclient.apikey.default-header.X-Api-Key (no Java at all)
whoAmI() -> {caller=demo-service, auth=api-key}
headers the upstream saw: X-Api-Key=demo-key-123, X-Correlation-Id present=true
# 4. the interceptor is per group: the "apikey" group never sends Authorization
Do not commit real keys. The values here are fake demo strings. In a real service read them from the environment or a secret store, and prefer the supplier-based interceptor so a rotated token takes effect without a restart. Transcript: 06-auth-bearer-and-api-key.txt.
OAuth2 client credentials with @ClientRegistrationId
Spring Security 7.1.2 has @ClientRegistrationId and OAuth2RestClientHttpServiceGroupConfigurer (both found in the spring-security-oauth2-client jar and run here). Annotate the interface, define a client registration in properties, and register the configurer built from an OAuth2AuthorizedClientManager (OAuthClient.java and HttpClientsConfig.java):
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clients) {
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
manager.setAuthorizedClientProvider(new ClientCredentialsOAuth2AuthorizedClientProvider());
return manager;
}
/** Only the group named in @ClientRegistrationId on the interface gets a token attached. */
@Bean
RestClientHttpServiceGroupConfigurer oauthGroups(OAuth2AuthorizedClientManager manager) {
return OAuth2RestClientHttpServiceGroupConfigurer.from(manager);
# @ClientRegistrationId("stub") on the interface + OAuth2RestClientHttpServiceGroupConfigurer
call 1: whoAmI() -> {caller=demo-user, tokenKind=oauth2}
call 2: whoAmI() -> {caller=demo-user, tokenKind=oauth2}
call 3: whoAmI() -> {caller=demo-user, tokenKind=oauth2}
# what the stub saw, in order
POST /oauth/token
GET /secure/bearer
GET /secure/bearer
GET /secure/bearer
token requests: 1
The token was fetched once and reused for three calls. Without the configurer bean the annotation does nothing:
# the same interface WITHOUT the group configurer: the annotation is silently ignored
whoAmI() -> 401 UNAUTHORIZED
The first version overstated this. It said proxies integrate with Spring Security OAuth2 “without any additional configuration”. Boot does not define the group configurer for you (I listed the auto-configuration imports of spring-boot-security-oauth2-client: nothing for HTTP service groups), so you register it yourself, as above. Transcript: 07-oauth2-client-credentials.txt.
Testing HTTP Service Clients: mock server, slice test and a real server
Three levels, each catching different things. Use the cheap one first and keep at least one against a real socket.
1. MockRestServiceServer, no Spring context
Bind the mock to the RestClient.Builder, build the proxy from it, and assert on the request your interface produced (Step08MockServerTest.java):
RestClient.Builder builder = RestClient.builder().baseUrl(BASE)
.defaultStatusHandler(s -> s.isError(), new UpstreamProblemStatusHandler());
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
PostClient client = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(builder.build()))
.build().createClient(PostClient.class);
server.expect(requestTo(BASE + "/posts")).andExpect(method(HttpMethod.POST))
.andExpect(header("Content-Type", "application/json"))
.andExpect(jsonPath("$.title").value("New Post"))
.andExpect(content().json("{\"userId\":1,\"title\":\"New Post\",\"body\":\"Content\"}"))
.andRespond(withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON)
.body("{\"id\":101,\"userId\":1,\"title\":\"New Post\",\"body\":\"Content\"}"));
Post created = client.create(new Post(null, 1, "New Post", "Content"));
# 1. GET: URL, method and the decoded record
findById(1) -> Post[id=1, userId=1, title=Test Title, body=Test body]
# 2. POST: the request body the interface sent
create(...) -> id 101 (request matched: path, method, Content-Type, JSON body)
# 3. a ProblemDetail 404 goes through the real status handler
findById(9) -> UpstreamNotFoundException (handler decoded the body: 'No post with id 9')
2. @RestClientTest
The slice annotation is org.springframework.boot.restclient.test.autoconfigure.RestClientTest in Boot 4 (javap), not the old ...test.autoconfigure.web.client package. Naming the interface in the annotation does not create a bean for it:
@RestClientTest(PostClient.class), then @Autowired PostClient -> no such bean (null)
Registering a group inside the slice gives you the client, but the auto-configured mock server is not bound to the group’s RestClient:
PostClient from the @ImportHttpServices group is injected: true
server.expect(...) -> IllegalStateException: Unable to use auto-configured MockRestServiceServer since a mock server customizer has not been bound to a RestTemplate or RestClient
What does work is building the proxy from the slice’s own RestClient.Builder, which the mock server is bound to (Step09RestClientSliceTest.java):
# a proxy built from the slice's own RestClient.Builder, which the mock server IS bound to
findById(1) -> Post[id=1, userId=1, title=From the mock, body=b]
A mock cannot tell you about timeouts or resets. The first version warned that @RestClientTest passes while staging times out. That is still the lesson: mocks never run the socket, the timeouts or the real serialisation. Baseline your read timeouts from measured latency, and keep real-server tests like the next one.
3. Against a real local server
Every other test in the module does this: StubServer.java is a JDK HttpServer started on a random port, wired in with @DynamicPropertySource (StubBackedTest.java). It is what produced the timeout, retry, 401 and OAuth transcripts above, and it needs no network. Run it by hand with ./scripts/run-stub.sh and curl. Transcripts: 08-mock-rest-service-server.txt, 09-rest-client-test-slice.txt.
Going deeper: what each level catches
Level
Catches
Misses
MockRestServiceServer
wrong URL, method, headers, JSON body; your status handler logic
timeouts, connection errors, real Jackson behaviour on odd payloads
@RestClientTest slice
Boot wiring of the builder and converters
group-built clients (not bound to the mock, shown above)
Real local server
timeouts, retries, auth flows, token caching, exceptions end to end
differences from the real upstream (contract drift)
HTTP Service Clients vs OpenFeign: my honest take after migrating both
I have used OpenFeign in production since Spring Cloud 2020.0 and migrated two services to HTTP Service Clients when moving to Boot 4. Here is what the comparison looks like in practice.
Where HTTP Service Clients win: no extra dependency and no Spring Cloud version alignment. The base-URL and timeout configuration in application.yml is cleaner than Feign’s @FeignClient(url = "${...}"). Testing with MockRestServiceServer is more ergonomic than Feign’s WireMock integration. Retry is now in the core framework, and OAuth2 works through @ClientRegistrationId once you register the one configurer bean.
Where OpenFeign still has an edge: Feign’s ErrorDecoder is a single obvious place for error-to-exception mapping, whereas the defaultStatusHandler takes the handler class shown above. Feign also has a wider ecosystem of community encoders and decoders. And if your team has years of Feign muscle memory, the migration cost is real.
Bottom line: for new Spring Boot 4 projects, use HTTP Service Clients from day one. For migrating Feign-heavy codebases, do it during a Boot 4 upgrade — not as a standalone refactor.
The Spring team’s own position. The Spring Cloud OpenFeign README states that the project is treated as feature-complete, that only bug fixes are planned (plus the odd small community PR), and it points users to Spring HTTP Service Clients. The latest GA of spring-cloud-starter-openfeign on Maven Central is 5.0.4. I read the README on 6 November 2026; I did not run Feign in this module.
Feature table
Concern
OpenFeign
HTTP Service Client (Boot 4.1)
Declare an endpoint
@FeignClient + @GetMapping
@HttpExchange / @GetExchange
Register
@EnableFeignClients
@ImportHttpServices(group = ...)
Base URL, timeouts
@FeignClient(url=...) and spring.cloud.openfeign.client.config.*
spring.http.serviceclient.<group>.*
Error mapping
feign.codec.ErrorDecoder
RestClient.Builder#defaultStatusHandler
Retry
feign.Retryer
@Retryable / RetryTemplate (Framework 7), Resilience4j, or an interceptor
The Feign names in the middle column are real classes: I confirmed feign.codec.ErrorDecoder, feign.Retryer, feign.RequestInterceptor, feign.Contract and feign.Client by listing the feign-core 13.17 jar. The rest of the Feign column is from its documentation, not run here.
Migrating from OpenFeign
If you are coming from Spring Cloud OpenFeign the core idea is the same — declarative interfaces — but the annotations differ. @FeignClient(name = "posts", url = "...") becomes @HttpExchange on the interface plus the group attribute in @ImportHttpServices. @RequestMapping(method = GET, value = "/posts") becomes @GetExchange("/posts"). @RequestLine("GET /posts/{id}") (native Feign) becomes @GetExchange("/posts/{id}"). @EnableFeignClients is replaced by @ImportHttpServices. The Feign error decoder is replaced by the RestClient defaultStatusHandler.
The biggest benefit is that you drop the spring-cloud-starter-openfeign dependency entirely. HTTP Service Clients are a core Spring Framework feature — no additional libraries and no Spring Cloud version alignment.
Going deeper: a migration order that keeps services running
Add the new module next to the old one; migrate one client interface at a time, not the whole service.
Move base URL and timeouts to spring.http.serviceclient.<group>.* and compare against the Feign config values.
Port the ErrorDecoder to a status handler and port its tests first; the transcripts above show the shape.
Check retries: Feign’s Retryer counts differently from @Retryable (see the retry diagram).
Delete @EnableFeignClients and the dependency only when no @FeignClient is left.
Spring Cloud and Spring Security integration
The HTTP Service Registry is designed to be extensible. For Spring Security I verified and ran the OAuth2 integration above. For Spring Cloud, the spring-cloud-commons 5.0.2 jar contains LoadBalancerRestClientHttpServiceGroupConfigurer and a circuitbreaker.httpservice package with CircuitBreakerRestClientHttpServiceGroupConfigurer and HttpServiceFallback. That is a jar listing, not a run, so I stay at “the building blocks exist” where the first version said they are provided automatically.
When to use HTTP Service Clients vs RestClient
HTTP Service Clients are best for well-defined, stable REST APIs — the kind where you know the endpoints and the request and response shapes and want a clean Java API. Use RestClient directly when you need full programmatic control: dynamically constructed URLs, streaming large responses, or one-off calls that do not justify an interface. Most projects use both. The RestClient vs WebClient vs HTTP interface clients post compares speed and error behaviour of the three.
Quick reference: all @HttpExchange annotations
@HttpExchange is the base annotation that specifies URL, HTTP method, content type and accept type. It can sit on the interface (shared settings) and on methods. @GetExchange is a shortcut for @HttpExchange(method = "GET"), and @PostExchange, @PutExchange, @DeleteExchange and @PatchExchange follow. All of them inherit the base path from the class-level @HttpExchange. I listed these five in the spring-web 7.0.10 jar.
The gotcha I hit on day one (save yourself 40 minutes)
When I first migrated a service from OpenFeign, everything compiled and tests passed — but the integration environment threw a NoSuchBeanDefinitionException for the interface on startup. The cause: I had placed @ImportHttpServices on a config class in a sub-package outside @SpringBootApplication’s component scan. HTTP Service Clients are registered via your config class, not by scanning the interfaces themselves, so that class must be scanned or imported. That one detail cost a frustrating hour.
A second trap: if your read-timeout is shorter than the API’s real p99 latency, a mocked test will pass and staging will intermittently time out. Baseline group timeouts from real latency data before committing to a value.
Common mistakes to avoid
Forgetting @ImportHttpServices means no bean, so a NoSuchBeanDefinitionException at startup. Omitting the base URL, or using the old property keys, fails on the first call with IllegalArgumentException: URI with undefined scheme (measured above). A wrong return type fails with a RestClientException whose root cause is Jackson’s MismatchedInputException. @RequestMapping in place of @HttpExchange compiles and then throws on the first call. And a retry on a non-idempotent call creates duplicates.
Should you use this? Yes for calling a handful of well-described REST APIs from a Boot 4 service: the interface is a contract you can read, and the group is where timeouts, auth and retries live. No if you need streaming, highly dynamic URLs or per-call tuning (use RestClient directly), and not as a reason to rip out a stable Feign setup outside a Boot 4 upgrade. Not every claim here covers reactive groups: the WebClient variant (Download hsc-WebClientGroup.java) compiles but was not executed, because it needs spring-webflux.
Do HTTP Service Clients work with WebClient (reactive)? The annotation has the attribute: @ImportHttpServices(clientType = HttpServiceGroup.ClientType.WEB_CLIENT) (checked with javap; the enum has REST_CLIENT, WEB_CLIENT, UNSPECIFIED), and return types can then be Mono or Flux. I did not execute a WebClient group, since it needs spring-webflux.
Can I share the interface between client and server? Yes. If you own both sides, define the interface in a shared module; the server-side @RestController implements it and the client uses it as an HTTP Service Client. That keeps the contract in sync. I did not build that here.
Does this replace RestTemplate? They are its recommended replacements: HTTP Service Clients for declarative use, RestClient for imperative use. In Spring Framework 7.0.10 the RestTemplate class itself carries no @Deprecated (checked with javap -v), so this is guidance, not a compiler warning, on that version.
Is OpenFeign still supported? It still works, but the project is feature-complete with bug fixes only, and its README points to Spring HTTP Service Clients.
Conclusion
HTTP Service Clients in Spring Boot 4 are the biggest quality-of-life improvement for REST API consumption in years. Declarative interfaces mean less code and a clean separation between “what endpoints exist” and “how they are called”. The group model scales from one API to dozens, and the pieces that matter in production — error mapping, retries, auth and tests — each have a small, testable place to live, as the transcripts above show.
If you are starting a new Spring Boot 4 project or upgrading, make HTTP Service Clients your default for consuming external REST APIs.
No Comments yet!