Ch. 8 · Spring Boot

Spring Declarative HTTP Clients

Turn an annotated Java interface into a working HTTP client with @HttpExchange, then add the timeouts and error mapping it doesn't give you.

~6 min readadvancedupdated Oct 19, 2026

Calling another service over HTTP used to mean one of two things in Spring: hand-written RestTemplate code in a wrapper class, or pulling in Spring Cloud OpenFeign. Since Spring Framework 6 (Spring Boot 3), there’s a built-in third option. You write a Java interface that describes the remote API with annotations, and Spring generates the implementation at runtime. The Spring reference calls these HTTP service clients; you’ll also hear “HTTP interfaces”.

The interface is the easy part. What makes the difference in production, and in interviews, is everything the interface doesn’t say: how long to wait, what a 404 should mean to your code, and what happens when the other service is having a bad day.

The Java below targets Spring Boot 3.2 or later on Java 17+, where RestClient is available. It’s illustrative and wasn’t compiled for this article.

Describing the remote API

Say an order service needs to call a shipping-rates service. The interface reads almost like a controller:

@HttpExchange(url = "/v1", accept = "application/json")
public interface ShippingClient {

    @GetExchange("/rates")
    List<Rate> rates(@RequestParam String from, @RequestParam String to,
                     @RequestParam int weightGrams);

    @PostExchange("/shipments")
    Shipment createShipment(@RequestBody NewShipment request,
                            @RequestHeader("Idempotency-Key") String key);

    @GetExchange("/shipments/{id}")
    ResponseEntity<Shipment> shipment(@PathVariable String id);
}
java

The type-level @HttpExchange sets defaults shared by every method. @GetExchange, @PostExchange, @PutExchange, @PatchExchange and @DeleteExchange set the method and path. Parameters use the annotations you know from controllers: @PathVariable, @RequestParam, @RequestHeader, @RequestBody, plus @RequestPart for multipart and @CookieValue.

Return types decide how much of the response you see. A plain Shipment gives you the decoded body. ResponseEntity<Shipment> adds the status and headers, useful when the caller needs an ETag or Location. void just performs the request. With a WebClient underneath you can return Mono and Flux as well.

Creating the proxy as a bean

Spring doesn’t scan for these interfaces automatically in Boot 3.x. You build each proxy with HttpServiceProxyFactory on top of a configured client:

@Configuration
public class ShippingClientConfig {

    @Bean
    ShippingClient shippingClient(RestClient.Builder builder,
                                  @Value("${shipping.base-url}") String baseUrl) {
        HttpClient jdk = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(2))
                .build();
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(jdk);
        factory.setReadTimeout(Duration.ofSeconds(3));

        RestClient restClient = builder
                .baseUrl(baseUrl)
                .requestFactory(factory)
                .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
                    throw new ShippingUnavailableException(
                            "shipping returned " + response.getStatusCode());
                })
                .build();

        return HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build()
                .createClient(ShippingClient.class);
    }
}
java

Inject Boot’s auto-configured RestClient.Builder rather than calling RestClient.create(). The Boot builder comes with the application’s Jackson configuration and is instrumented by Micrometer, so outgoing calls get metrics and trace headers. A bare RestClient.create() has none of that, which is a common reason distributed traces stop at one service.

Spring Framework 7 (Spring Boot 4) adds “HTTP service groups” with @ImportHttpServices, which registers proxies as beans for you and configures each group’s client from properties. If you’re on Boot 3.x, the explicit bean above is the way.

Timeouts: the part the interface can’t express

The interface has no notion of time. Whatever client you put underneath decides how long a call may take, and the defaults are generous to the point of danger. The JDK’s HttpClient, for instance, has no request timeout unless you set one, and no connect timeout beyond what the operating system imposes.

Work through what that means. A Tomcat server has 200 request threads by default. Suppose 50 requests a second need a shipping quote, and the shipping service stops responding without closing connections (a stuck load balancer, a full garbage-collection pause, a firewall dropping packets). With no timeout, each request thread waits indefinitely. At 50 new requests per second, all 200 threads are stuck after about four seconds, and now the order service can’t answer anything, including health checks and requests that never touch shipping. One slow dependency has taken down its caller.

So set both timeouts deliberately:

  • Connect timeout: how long to wait for a TCP connection. Short, around 1 to 2 seconds, because a healthy service in the same region connects in milliseconds.
  • Read (response) timeout: how long to wait for the response once connected. Base it on the dependency’s observed p99 plus margin, and on how long your caller will wait. If your own endpoint promises a 2-second response, a 5-second read timeout downstream makes no sense.

Timeouts surface as ResourceAccessException, wrapping the underlying I/O exception (for the JDK client, java.net.http.HttpTimeoutException). That’s different from an HTTP error response, and your code should treat it differently: a timeout means you don’t know whether a POST was processed, which is why createShipment above sends an idempotency key.

For anything beyond a single timeout, such as retries with backoff, circuit breakers and bulkheads, use Resilience4j around the client call. The interface itself has no retry hook.

Error handling at the boundary

By default, RestClient throws on any 4xx or 5xx. You get HttpClientErrorException or HttpServerErrorException, with specific subclasses like HttpClientErrorException.NotFound, all extending RestClientResponseException, which exposes the status code, headers and response body.

Letting those escape into your service layer couples business code to HTTP details. Better to decide at the client boundary what each failure means:

  • A 404 on GET /shipments/{id} might mean “no such shipment”, which the caller should handle as an empty result, not an error. Return ResponseEntity and check the status, or catch NotFound in a thin wrapper and return Optional.empty().
  • A 409 or 422 usually carries a problem-details body explaining why the request was rejected. Read it into a domain exception with a message your callers can act on.
  • 5xx and timeouts mean the dependency is unhealthy. Map them to one exception type (ShippingUnavailableException above) that your own error handler turns into a 503 or a degraded response, such as showing standard shipping rates from configuration.

Logging the response body on errors is useful, but be careful with size and with sensitive fields; truncate it and never log authorization headers.

Testing the client

Because the client is just an interface, unit tests of code that uses it can mock it directly. To test the client itself (paths, query parameters, headers, error mapping), point it at a stub server. MockRestServiceServer can bind to a RestClient.Builder, and WireMock gives you a real HTTP server where you can also simulate delays, which is the only practical way to prove your read timeout fires:

stubFor(get(urlPathEqualTo("/v1/rates"))
        .willReturn(okJson("[]").withFixedDelay(5_000)));

assertThatThrownBy(() -> client.rates("SW1A", "EC1A", 900))
        .isInstanceOf(ResourceAccessException.class);
java

Practice question: Feign or HTTP interfaces?

Your team is moving an older Spring Boot 2 service, which uses Spring Cloud OpenFeign for six downstream clients, to Spring Boot 3. A colleague proposes switching to @HttpExchange interfaces at the same time. What would you check before agreeing, and what has to be rebuilt?

Answer. The programming model is similar, so the interface definitions mostly translate one-to-one: @FeignClient methods become @GetExchange and friends, and parameter annotations largely carry over. The work is in the parts Feign gave you implicitly. Feign clients are created as beans automatically from @EnableFeignClients; on Boot 3.x you write a factory bean per client (or move to Boot 4’s service groups). Feign’s configuration properties for connect and read timeouts, its ErrorDecoder and its RequestInterceptors need equivalents: timeouts on the request factory, a defaultStatusHandler or wrapper for error mapping, and RestClient interceptors for auth headers. If any client relied on Spring Cloud LoadBalancer for service discovery, check that a @LoadBalanced RestClient.Builder is configured. Retries configured in Feign should move to Resilience4j.

Spring Cloud OpenFeign itself is in maintenance mode, which is a fair argument for switching. But doing it in the same change as a major Boot upgrade makes failures harder to attribute. A safer plan is to upgrade Boot with Feign first, then migrate clients one at a time, each with a stub-server test that covers the timeout and error mapping before and after.

Keep reading

More in Spring Boot

read ✓Spring Boot · easy

@Controller vs @RestController in Spring Boot

@RestController is @Controller plus @ResponseBody: return values become the response body, not view names. See both styles and the usual bugs.

~6 min readread →
esc