Circuit Breakers and Retries with Resilience4j

Introduction

A call inside one application either works or throws. A call to another service over the network has more ways to go wrong: the other service is down, it is restarting, it answers after thirty seconds, or it answers one request in ten with an error. In a system of microservices some service is always in one of these states, and a caller that simply waits and fails passes the problem on to its own callers. Resilience4j is a library of small tools for this: retry for failures that pass, the circuit breaker for failures that last, the time limiter for calls that hang, and the bulkhead and rate limiter for protecting capacity. This tutorial uses them through annotations in a Spring Boot application and watches each one work, including a circuit breaker going through its three states. The code runs on Spring Boot 4.1.2 with Resilience4j 2.4.0.

Setup

Resilience4j has a Spring Boot module that creates the retries and circuit breakers from properties and applies them with annotations. The annotations work through Spring AOPClick to read tutorial: Spring AOP Explained: Aspects, Advice and Pointcuts, so the AspectJ starter is needed as well -
pom.xml (extract)
<dependency>
  <groupId>io.github.resilience4j</groupId>
  <artifactId>resilience4j-spring-boot4</artifactId>
  <version>2.4.0</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
In a Spring Cloud project the starter spring-cloud-starter-circuitbreaker-resilience4j brings the same library behind Spring Cloud's own CircuitBreakerFactory interface; the gateway's CircuitBreaker filterClick to read tutorial: API Gateway with Spring Cloud Gateway uses that. The annotations shown here are Resilience4j's own and need only the module above.
Spring Framework 7 has its own @Retryable and @ConcurrencyLimit, covered in Resilience in Spring Framework 7Click to read tutorial: Resilience in Spring 7: @Retryable and @ConcurrencyLimit. They need no extra library and are enough for a simple retry. Resilience4j adds what they do not have: the circuit breaker, the time limiter, the rate limiter, and metrics for all of them.

A remote service to call

To see each pattern react, the failures must be under control. This class stands in for the HTTP calls to a catalog, an inventory and a review service; the program can tell it when to fail -
RemoteCatalog.java - stands in for calls to other services
//RemoteCatalog.java
package com.javabrahman.cloud.resilience;

import org.springframework.stereotype.Component;

@Component
public class RemoteCatalog {

  private int priceFailuresLeft;
  private boolean inventoryUp = true;

  public void failNextPriceCalls(int count) { priceFailuresLeft = count; }
  public void setInventoryUp(boolean up) { inventoryUp = up; }

  public int fetchPrice(String isbn) {
    if (isbn.equals("000")) {
      throw new BookNotFoundException("no book with ISBN " + isbn);
    }
    if (priceFailuresLeft > 0) {
      priceFailuresLeft--;
      System.out.println("      catalog service called - fails");
      throw new RemoteCallException("catalog service did not answer");
    }
    System.out.println("      catalog service called - answers 450");
    return 450;
  }

  public int fetchStock(String isbn) {
    if (!inventoryUp) {
      System.out.println("      inventory service called - fails");
      throw new RemoteCallException("inventory service did not answer");
    }
    System.out.println("      inventory service called - answers 12");
    return 12;
  }

  public String fetchReviews(String isbn) {
    try {
      Thread.sleep(3000);   // a service that has become very slow
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }
    return "4.7 stars";
  }
}
RemoteCallException.java
//RemoteCallException.java
package com.javabrahman.cloud.resilience;

public class RemoteCallException extends RuntimeException {
  public RemoteCallException(String message) { super(message); }
}
BookNotFoundException.java
//BookNotFoundException.java
package com.javabrahman.cloud.resilience;

public class BookNotFoundException extends RuntimeException {
  public BookNotFoundException(String message) { super(message); }
}
The two exceptions are different in kind. RemoteCallException is a technical failure: trying again may help. BookNotFoundException is a business answer: the service worked and the book does not exist, however often it is asked.

The service with the annotations

BookService.java
//BookService.java
package com.javabrahman.cloud.resilience;

import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeoutException;
import org.springframework.stereotype.Service;

@Service
public class BookService {

  private final RemoteCatalog remote;

  BookService(RemoteCatalog remote) {
    this.remote = remote;
  }

  @Retry(name = "price", fallbackMethod = "lastKnownPrice")
  public int price(String isbn) {
    return remote.fetchPrice(isbn);
  }

  private int lastKnownPrice(String isbn, RemoteCallException e) {
    System.out.println("      fallback: the last known price");
    return 440;
  }

  @CircuitBreaker(name = "inventory", fallbackMethod = "stockUnknown")
  public int stock(String isbn) {
    return remote.fetchStock(isbn);
  }

  private int stockUnknown(String isbn, RemoteCallException e) {
    return -1;   // the call was made and failed
  }

  private int stockUnknown(String isbn, CallNotPermittedException e) {
    System.out.println("      circuit is open - inventory service NOT called");
    return -1;   // the call was not made at all
  }

  @TimeLimiter(name = "reviews", fallbackMethod = "noReviews")
  public CompletableFuture<String> reviews(String isbn) {
    return CompletableFuture.supplyAsync(() -> remote.fetchReviews(isbn));
  }

  private CompletableFuture<String> noReviews(String isbn, TimeoutException e) {
    return CompletableFuture.completedFuture("reviews are not available right now");
  }
}
src/main/resources/application.properties
# retry "price": 3 attempts in all, waiting 200 ms and then 400 ms
resilience4j.retry.instances.price.max-attempts=3
resilience4j.retry.instances.price.wait-duration=200ms
resilience4j.retry.instances.price.enable-exponential-backoff=true
resilience4j.retry.instances.price.exponential-backoff-multiplier=2
resilience4j.retry.instances.price.ignore-exceptions=com.javabrahman.cloud.resilience.BookNotFoundException

# circuit breaker "inventory": open when half of the last 4 calls failed, try again after 2 seconds
resilience4j.circuitbreaker.instances.inventory.sliding-window-type=COUNT_BASED
resilience4j.circuitbreaker.instances.inventory.sliding-window-size=4
resilience4j.circuitbreaker.instances.inventory.minimum-number-of-calls=4
resilience4j.circuitbreaker.instances.inventory.failure-rate-threshold=50
resilience4j.circuitbreaker.instances.inventory.wait-duration-in-open-state=2s
resilience4j.circuitbreaker.instances.inventory.permitted-number-of-calls-in-half-open-state=2

# time limiter "reviews": give up after half a second
resilience4j.timelimiter.instances.reviews.timeout-duration=500ms
How the pieces connect -
  • Each annotation names an instance - price, inventory, reviews - and the properties configure the instance of that name. The numbers stay out of the code and can differ per environment.
  • A fallback method has the parameters of the original method plus one exception. It is called when the pattern gives up, and only for exceptions of that type; several fallbacks with different exception types can share a name, as stockUnknown does.
  • The annotations work through a proxy, like @Transactional. A call from another method of the same class goes around the proxy and gets no protection - the self-invocation trapClick to read tutorial: How Spring Proxies Work and the Self-Invocation Trap.
  • @TimeLimiter needs a method that returns a CompletableFuture (or a reactive type): only an asynchronous call can be abandoned while it is still running.
The program below calls the three methods while switching the failures on and off. It also listens to the circuit breaker's state changes -
ResilienceApp.java
//ResilienceApp.java
package com.javabrahman.cloud.resilience;

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class ResilienceApp {

  @Bean
  CommandLineRunner demo(BookService books, RemoteCatalog remote, CircuitBreakerRegistry breakers) {
    return args -> {
      System.out.println("1. Retry - the catalog service fails twice, then works");
      remote.failNextPriceCalls(2);
      long start = System.currentTimeMillis();
      System.out.println("   price = " + books.price("978-1") + ", after " + (System.currentTimeMillis() - start) + " ms");

      System.out.println("2. Retry - the catalog service keeps failing");
      remote.failNextPriceCalls(10);
      System.out.println("   price = " + books.price("978-1"));

      System.out.println("3. Retry - an error that retrying cannot fix");
      try {
        books.price("000");
      } catch (BookNotFoundException e) {
        System.out.println("   BookNotFoundException: " + e.getMessage());
      }

      CircuitBreaker breaker = breakers.circuitBreaker("inventory");
      breaker.getEventPublisher().onStateTransition(event ->
          System.out.println("   >>> " + event.getStateTransition()));

      System.out.println("4. Circuit breaker - the inventory service goes down");
      remote.setInventoryUp(false);
      for (int call = 1; call <= 6; call++) {
        System.out.println("   call " + call + ": stock = " + books.stock("978-1") + "   [" + breaker.getState() + "]");
      }

      CircuitBreaker.Metrics metrics = breaker.getMetrics();
      System.out.println("   failure rate " + metrics.getFailureRate() + "%, calls rejected while open: "
          + metrics.getNumberOfNotPermittedCalls());

      System.out.println("5. Circuit breaker - two seconds later, the inventory service is back");
      Thread.sleep(2100);
      remote.setInventoryUp(true);
      for (int call = 7; call <= 9; call++) {
        System.out.println("   call " + call + ": stock = " + books.stock("978-1") + "   [" + breaker.getState() + "]");
      }


      System.out.println("6. Time limiter - the review service takes 3 seconds");
      start = System.currentTimeMillis();
      String reviews = books.reviews("978-1").get();
      System.out.println("   " + reviews + ", after " + (System.currentTimeMillis() - start) + " ms");
      System.exit(0);
    };
  }

  public static void main(String[] args) {
    SpringApplication.run(ResilienceApp.class, args);
  }
}
 OUTPUT of the above code
1. Retry - the catalog service fails twice, then works
      catalog service called - fails
      catalog service called - fails
      catalog service called - answers 450
   price = 450, after 759 ms
2. Retry - the catalog service keeps failing
      catalog service called - fails
      catalog service called - fails
      catalog service called - fails
      fallback: the last known price
   price = 440
3. Retry - an error that retrying cannot fix
   BookNotFoundException: no book with ISBN 000
4. Circuit breaker - the inventory service goes down
      inventory service called - fails
   call 1: stock = -1   [CLOSED]
      inventory service called - fails
   call 2: stock = -1   [CLOSED]
      inventory service called - fails
   call 3: stock = -1   [CLOSED]
      inventory service called - fails
   >>> State transition from CLOSED to OPEN
   call 4: stock = -1   [OPEN]
      circuit is open - inventory service NOT called
   call 5: stock = -1   [OPEN]
      circuit is open - inventory service NOT called
   call 6: stock = -1   [OPEN]
   failure rate 100.0%, calls rejected while open: 2
5. Circuit breaker - two seconds later, the inventory service is back
   >>> State transition from OPEN to HALF_OPEN
      inventory service called - answers 12
   call 7: stock = 12   [HALF_OPEN]
      inventory service called - answers 12
   >>> State transition from HALF_OPEN to CLOSED
   call 8: stock = 12   [CLOSED]
      inventory service called - answers 12
   call 9: stock = 12   [CLOSED]
6. Time limiter - the review service takes 3 seconds
   reviews are not available right now, after 570 ms
The next three sections go through this output.

Retry

Many failures are gone a moment later: a dropped connection, a service instance that was just replaced, a short overload. Retry repeats the call a limited number of times, with a pause in between. Retry with exponential backoff - attempt 1 fails, a wait of 200 ms, attempt 2 fails, a wait of 400 ms, attempt 3 succeeds and the caller gets the result; if the last attempt fails too, the fallback answers In the output -
  • Part 1 - the first two calls failed and the third worked. The caller saw only the result, 450, a little over 600 ms later: the two pauses of 200 ms and 400 ms. With exponential backoff each pause is longer than the one before, which gives a struggling service room to recover.
  • Part 2 - all three attempts failed. max-attempts=3 counts the first call too, so there was no fourth. The fallback then answered with the last known price.
  • Part 3 - BookNotFoundException is in ignore-exceptions: no retry, and no fallback either, since the fallback is declared for RemoteCallException. The exception reached the caller unchanged.
In production add jitter - enable-randomized-wait=true - so that many clients which failed at the same moment do not all retry at the same moment.

Circuit breaker

Retrying helps when the failure is short. When a service is down for minutes, retries make things worse: every caller waits for every attempt to time out, threads pile up, and the struggling service is hit with three times the traffic just when it tries to recover. The circuit breaker watches the results of the calls, and when too many fail it stops calling for a while and fails at once. It has three states - The three states of a circuit breaker - CLOSED: calls pass and results are recorded; when the failure rate reaches the threshold it becomes OPEN: calls are rejected at once without reaching the service; after the wait duration it becomes HALF_OPEN: a few trial calls pass; if they succeed the breaker closes, if they fail it opens again In the output -
  • Calls 1 to 4 - the breaker was CLOSED: each call reached the inventory service and failed. The breaker needs minimum-number-of-calls results before it judges; at the fourth it had them, the failure rate was 100% against a threshold of 50%, and it went from CLOSED to OPEN.
  • Calls 5 and 6 - the breaker was OPEN. The inventory service was not called: the breaker threw CallNotPermittedException at once and the fallback for that exception answered. The caller lost no time, and the failing service got no traffic.
  • Call 7 - the two seconds of wait-duration-in-open-state had passed, so the breaker went to HALF_OPEN and let the call through as a trial. It succeeded.
  • Call 8 - the second trial call succeeded as well. That was the number of trial calls configured, none had failed, and the breaker went back to CLOSED. Had the trials failed, it would have opened for another two seconds.
  • Call 9 - business as usual.
The window here is tiny so that the output stays short. Real settings look at more calls - a window of 50 or 100, or a time-based window of the last minute - and can also count slow calls as failures with slow-call-duration-threshold.

Time limiter

A slow answer can hurt more than no answer: a failed connection returns in milliseconds, while a hanging call holds a thread and a waiting user for as long as it likes. The time limiter puts a limit on the wait. In part 6 of the output the review service needed three seconds, and the caller had its fallback answer after about half a second - the timeout-duration. The abandoned call still runs to its end in the background; the limit protects the caller, not the called service. HTTP clients have their own connect and read timeouts, and those should always be set as well.

Bulkhead and rate limiter

Two more patterns come with the same module and are configured in the same way -
  • @Bulkhead limits how many calls to one service may run at the same time. If the review service hangs, at most that many threads hang with it, and the rest of the application keeps working. The name comes from the walls that divide a ship's hull so that one leak does not sink it.
  • @RateLimiter limits how many calls may start in a period - for example 50 per second to a partner API that bills, or blocks, above that.
application.properties - a bulkhead and a rate limiter
resilience4j.bulkhead.instances.reviews.max-concurrent-calls=10
resilience4j.bulkhead.instances.reviews.max-wait-duration=0

resilience4j.ratelimiter.instances.partner.limit-for-period=50
resilience4j.ratelimiter.instances.partner.limit-refresh-period=1s
resilience4j.ratelimiter.instances.partner.timeout-duration=0

Combining the patterns

One method can carry several of these annotations. Resilience4j applies them in a fixed order, whatever order they are written in: the bulkhead is closest to the method, then the time limiter, the rate limiter, the circuit breaker, and the retry outermost. The order in which Resilience4j wraps a method call - Retry outermost, then CircuitBreaker, RateLimiter, TimeLimiter and Bulkhead closest to the remote call The order has consequences worth knowing -
  • Each retry attempt passes through the circuit breaker and is counted by it as a call of its own.
  • When the breaker is open, the retry receives a CallNotPermittedException for each attempt. Add that exception to the retry's ignore-exceptions, or the retry waits and tries in vain.
  • The time limit applies to each attempt, not to all of them together. Three attempts with a 2-second limit and waits in between can keep a user waiting more than 6 seconds; choose the numbers together.
  • Retry only what is safe to repeat. Reading a price twice is harmless; sending a payment twice is not, unless the receiving service recognises the repeat.
The states and counters of all instances are published as metrics through Micrometer, and the circuit breakers can be shown in the Actuator health endpoint - an open circuit is something operations should see.

Summary

In this tutorial we protected calls to other services with Resilience4j's annotations, configured from properties. @Retry repeated a failing call with growing pauses, fell back after the last attempt, and left a business error alone. @CircuitBreaker opened after half of four calls had failed, rejected the next calls without touching the service, let trial calls through after two seconds and closed when they succeeded. @TimeLimiter cut a three-second call off after half a second. Fallback methods turned each failure into a usable answer. With calls crossing several services, the next problem is seeing what happened to one request along the way: distributed tracing with Micrometer and OpenTelemetryClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry.