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, with RestClient 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 like WebClient but 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 (or WebClient).
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);
  }
}
The application is started with these properties -
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.Builder and builds a client with a baseUrl, so that each call only names the path. Building the client once, in the constructor, and reusing it is the intended use: a RestClient is thread-safe.
  • get().uri("/inventory/{isbn}", isbn) fills in the URI template - with proper encoding - and retrieve().body(Stock.class) converts the JSON response into a record, the same Jackson conversion a controller uses for @RequestBody.
  • For the reservation, toEntity() returned a ResponseEntity with the remote status and headers as well as the body, so the store could read the inventory's 201 and its Location header. The URI builder added the quantity query parameter.
  • For an unknown ISBN, the inventory answered 404. Without onStatus(), RestClient throws HttpClientErrorException.NotFound for any 4xx; here the handler turned it into the store's own InventoryNotFoundException, which the advice made a 404 problem. 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 a ResourceAccessException, which the advice answered with a 503 problem - 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) sent GET /inventory/{isbn} to the group's base URL and converted the response - the same request as the RestClient version, without any request-building code.
  • A method returning ResponseEntity<Reservation> gives access to the status and headers, like toEntity().
  • The unknown ISBN made the generated client throw HttpClientErrorException.NotFound, which the advice mapped to a 404 problem.
  • 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. RestClient directly 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-timeout and read-timeout set them for every client; spring.http.serviceclient.<group>.read-timeout overrides 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 RestClient once 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 @Retryable is covered in module 7, and Resilience4j in module 10.
  • Test without the real service: Spring's MockRestServiceServer or 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 why RestClient 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.