Calling REST Services with RestClient and HTTP Interfaces
Introduction
This tutorial turns to the client side of REST: calling other HTTP services from a Spring Boot application, withRestClient and with HTTP interface clients. We will start with the clients Spring offers and why RestClient is the one to use for blocking code. We will then call a service with RestClient - reading a JSON body, sending a POST and reading the response's status and headers - and handle a 404 from the remote service and a read timeout. Next we will declare the same calls as a Java interface with @GetExchange and @PostExchange, and let Spring Boot 4's @ImportHttpServices create the client, configured entirely by properties. Lastly, we will list good practices for calling remote services.(This tutorial uses the error format from REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail. Sending an API version from a client is shown in API Versioning in Spring Framework 7Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4.)
Spring's HTTP clients
Spring has offered several HTTP clients over the years -RestTemplate: the original, template-style client. It still works but receives no new features; new code should not use it.WebClient: the reactive client from Spring WebFlux, for non-blocking code (covered in module 8).RestClient: added in Spring Framework 6.1, with a fluent API likeWebClientbut for ordinary blocking code - the right choice for Spring MVC applications, and a natural fit with virtual threads.- HTTP interface clients: a Java interface with annotated methods, for which Spring generates an implementation backed by
RestClient(orWebClient).
spring-boot-starter-restclient brings RestClient support: an auto-configured, injectable RestClient.Builder that already has Spring Boot's JSON converters and HTTP client settings.
The example: a store calling an inventory service
In a real system, the inventory would be a separate service on another machine. To keep the example in one program, the application below plays both roles:InventoryController is the "remote" service under /inventory, and the store endpoints under /api/store call it over real HTTP, at the URL given by the inventory.url property -
StoreApp.java - an inventory service, and a store that calls it with RestClient and an HTTP interface
//StoreApp.java
package com.javabrahman.rest.client;
import java.net.URI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
import org.springframework.web.service.registry.ImportHttpServices;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
record Stock(String isbn, int quantity, String warehouse) {
}
record Reservation(String isbn, int quantity, String reservationId) {
}
// ---- The "remote" inventory service ----
@RestController
@RequestMapping("/inventory")
class InventoryController {
@GetMapping("/{isbn}")
ResponseEntity<Stock> stock(@PathVariable String isbn) {
return isbn.startsWith("978")
? ResponseEntity.ok(new Stock(isbn, 14, "Leeds"))
: ResponseEntity.notFound().build();
}
@PostMapping("/{isbn}/reservations")
ResponseEntity<Reservation> reserve(@PathVariable String isbn, @RequestParam int quantity) {
Reservation r = new Reservation(isbn, quantity, "R-1001");
URI location = ServletUriComponentsBuilder.fromCurrentRequestUri() // the request URL without its query string
.path("/{id}").buildAndExpand(r.reservationId()).toUri();
return ResponseEntity.created(location).body(r);
}
@GetMapping("/slow/{isbn}")
Stock slow(@PathVariable String isbn) throws InterruptedException {
Thread.sleep(3000); // a service having a bad day
return new Stock(isbn, 14, "Leeds");
}
}
// ---- The store, version 1: RestClient ----
class InventoryNotFoundException extends RuntimeException {
InventoryNotFoundException(String isbn) {
super("The inventory service has no record of ISBN " + isbn);
}
}
@RestController
@RequestMapping("/api/store")
class StoreController {
private final RestClient inventory;
StoreController(RestClient.Builder builder, @Value("${inventory.url}") String inventoryUrl) {
this.inventory = builder.baseUrl(inventoryUrl).build();
}
@GetMapping("/books/{isbn}/stock")
String stock(@PathVariable String isbn) {
Stock stock = inventory.get()
.uri("/inventory/{isbn}", isbn)
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new InventoryNotFoundException(isbn);
})
.body(Stock.class);
return stock.quantity() + " copies in " + stock.warehouse();
}
@PostMapping("/books/{isbn}/reserve")
String reserve(@PathVariable String isbn, @RequestParam int quantity) {
ResponseEntity<Reservation> response = inventory.post()
.uri(uriBuilder -> uriBuilder.path("/inventory/{isbn}/reservations").queryParam("quantity", quantity).build(isbn))
.retrieve()
.toEntity(Reservation.class);
return "inventory answered " + response.getStatusCode().value() + ", reservation " + response.getBody().reservationId()
+ " at " + response.getHeaders().getLocation();
}
@GetMapping("/slow/{isbn}")
Stock slow(@PathVariable String isbn) {
return inventory.get().uri("/inventory/slow/{isbn}", isbn).retrieve().body(Stock.class);
}
}
// ---- The store, version 2: an HTTP interface client ----
@HttpExchange("/inventory")
interface InventoryClient {
@GetExchange("/{isbn}")
Stock stock(@PathVariable String isbn);
@PostExchange("/{isbn}/reservations")
ResponseEntity<Reservation> reserve(@PathVariable String isbn, @RequestParam int quantity);
}
@RestController
@RequestMapping("/api/store/v2")
class StoreControllerV2 {
private final InventoryClient inventory;
StoreControllerV2(InventoryClient inventory) {
this.inventory = inventory;
}
@GetMapping("/books/{isbn}/stock")
String stock(@PathVariable String isbn) {
Stock stock = inventory.stock(isbn);
return stock.quantity() + " copies in " + stock.warehouse() + " (via the HTTP interface)";
}
@PostMapping("/books/{isbn}/reserve")
String reserve(@PathVariable String isbn, @RequestParam int quantity) {
ResponseEntity<Reservation> response = inventory.reserve(isbn, quantity);
return "reservation " + response.getBody().reservationId() + " at " + response.getHeaders().getLocation();
}
}
// ---- Turning remote failures into problems for our own clients ----
@RestControllerAdvice
class RemoteErrorHandler {
@ExceptionHandler(InventoryNotFoundException.class)
ProblemDetail notFound(InventoryNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(HttpClientErrorException.NotFound.class)
ProblemDetail remoteNotFound(HttpClientErrorException.NotFound e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "The inventory service answered 404");
}
@ExceptionHandler(ResourceAccessException.class)
ProblemDetail unavailable(ResourceAccessException e) {
return ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(503),
"The inventory service did not answer in time. Please try again later.");
}
}
@SpringBootApplication
@ImportHttpServices(group = "inventory", types = InventoryClient.class)
public class StoreApp {
public static void main(String[] args) {
SpringApplication.run(StoreApp.class, args);
}
}
application.properties - the URLs of the inventory service, and timeouts
inventory.url=http://localhost:8080
spring.http.clients.read-timeout=1s
spring.http.serviceclient.inventory.base-url=http://localhost:8080
spring.mvc.problemdetails.enabled=true
Calling a service with RestClient
OUTPUT of store requests served with RestClient> curl -i http://localhost:8080/api/store/books/9780134685991/stock
HTTP/1.1 200
Content-Length: 18
Content-Type: text/plain; charset=UTF-8
14 copies in Leeds
> curl -i -X POST http://localhost:8080/api/store/books/9780134685991/reserve?quantity=2
HTTP/1.1 200
Content-Length: 111
Content-Type: text/plain; charset=UTF-8
inventory answered 201, reservation R-1001 at http://localhost:8080/inventory/9780134685991/reservations/R-1001
> curl -i http://localhost:8080/api/store/books/0000000000000/stock
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"The inventory service has no record of ISBN 0000000000000","instance":"/api/store/books/0000000000000/stock","status":404,"title":"Not Found"}
> curl -i http://localhost:8080/api/store/slow/9780134685991
HTTP/1.1 503
Content-Type: application/problem+json
{"detail":"The inventory service did not answer in time. Please try again later.","instance":"/api/store/slow/9780134685991","status":503,"title":"Service Unavailable"}
Explanation of the code and output
- The controller receives Spring Boot's
RestClient.Builderand builds a client with abaseUrl, so that each call only names the path. Building the client once, in the constructor, and reusing it is the intended use: aRestClientis thread-safe. get().uri("/inventory/{isbn}", isbn)fills in the URI template - with proper encoding - andretrieve().body(Stock.class)converts the JSON response into a record, the same Jackson conversion a controller uses for@RequestBody.- For the reservation,
toEntity()returned aResponseEntitywith the remote status and headers as well as the body, so the store could read the inventory's201and itsLocationheader. The URI builder added thequantityquery parameter. - For an unknown ISBN, the inventory answered
404. WithoutonStatus(),RestClientthrowsHttpClientErrorException.NotFoundfor any 4xx; here the handler turned it into the store's ownInventoryNotFoundException, which the advice made a404problem. Translating remote errors keeps the inventory's details out of the store's API. - The slow endpoint took 3 seconds, longer than the 1-second
spring.http.clients.read-timeout. The call failed with aResourceAccessException, which the advice answered with a503problem - after about one second, instead of making the store's client wait.
HTTP interface clients
InventoryClient describes the same remote calls as a plain Java interface: @HttpExchange("/inventory") sets the common path, and @GetExchange and @PostExchange map each method to a request, with the same @PathVariable and @RequestParam annotations a controller uses. There is no implementation class - Spring generates one.In Spring Framework 7 and Spring Boot 4,
@ImportHttpServices(group = "inventory", types = InventoryClient.class) registers the interface as a bean in an HTTP service group named inventory, and Spring Boot configures the group from spring.http.serviceclient.inventory.* properties - here the base-url; timeouts, default headers and an API version can be set per group the same way. The second store controller simply injects the interface -
OUTPUT of store requests served with the HTTP interface> curl -i http://localhost:8080/api/store/v2/books/9781617293566/stock
HTTP/1.1 200
Content-Length: 43
Content-Type: text/plain; charset=UTF-8
14 copies in Leeds (via the HTTP interface)
> curl -i -X POST http://localhost:8080/api/store/v2/books/9781617293566/reserve?quantity=1
HTTP/1.1 200
Content-Length: 87
Content-Type: text/plain; charset=UTF-8
reservation R-1001 at http://localhost:8080/inventory/9781617293566/reservations/R-1001
> curl -i http://localhost:8080/api/store/v2/books/1234567890123/stock
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"The inventory service answered 404","instance":"/api/store/v2/books/1234567890123/stock","status":404,"title":"Not Found"}
Explanation of the output
- Calling
inventory.stock(isbn)sentGET /inventory/{isbn}to the group's base URL and converted the response - the same request as theRestClientversion, without any request-building code. - A method returning
ResponseEntity<Reservation>gives access to the status and headers, liketoEntity(). - The unknown ISBN made the generated client throw
HttpClientErrorException.NotFound, which the advice mapped to a404problem. - HTTP interfaces suit services with many endpoints: the interface documents the remote API in one place, and tests can replace it with a stub implementation.
RestClientdirectly suits one-off calls and calls that need custom handling of each response.
Good practices for calling remote services
- Always set timeouts. Without a read timeout, a hanging remote service makes your threads wait indefinitely, until your own service stops responding too.
spring.http.clients.connect-timeoutandread-timeoutset them for every client;spring.http.serviceclient.<group>.read-timeoutoverrides them per group. - Translate remote errors into your own exceptions and status codes, as the advice did, rather than passing the remote service's errors straight through.
- Reuse clients: build a
RestClientonce per remote service, or inject an HTTP interface bean. - Configure, do not hard-code, base URLs - each environment has its own, as shown in Spring Boot ProfilesClick to read tutorial: Spring Boot Profiles and Environment-Specific Configuration.
- Plan for failure: retries with backoff for temporary errors, and circuit breakers to stop calling a service that is down. Spring Framework 7's
@Retryableis covered in module 7, and Resilience4j in module 10. - Test without the real service: Spring's
MockRestServiceServeror a stub HTTP server lets tests check the requests your client sends and feed it canned responses (module 6).
Summary
In this tutorial we first compared Spring's HTTP clients and saw whyRestClient is the choice for blocking code. We then called an inventory service with RestClient over real HTTP: reading a JSON body, sending a POST and reading its 201 and Location with toEntity(), turning a remote 404 into our own exception with onStatus(), and answering 503 when a read timeout expired. Next we declared the same calls as an HTTP interface with @GetExchange and @PostExchange, registered it with @ImportHttpServices, and configured it with spring.http.serviceclient properties. Lastly, we listed good practices for calling remote services. The next tutorial documents our own API for its clients: OpenAPI with springdocClick to read tutorial: Documenting Spring Boot REST APIs with OpenAPI and springdoc.Spring REST API Tutorials
Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Documenting Spring Boot REST APIs with OpenAPI and springdocClick to read tutorial: Documenting Spring Boot REST APIs with OpenAPI and springdoc Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail
Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Documenting Spring Boot REST APIs with OpenAPI and springdocClick to read tutorial: Documenting Spring Boot REST APIs with OpenAPI and springdoc Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail