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>
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); }
}
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
- 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
stockUnknowndoes. - 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. @TimeLimiterneeds a method that returns aCompletableFuture(or a reactive type): only an asynchronous call can be abandoned while it is still running.
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);
}
}
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
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.
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=3counts the first call too, so there was no fourth. The fallback then answered with the last known price. - Part 3 -
BookNotFoundExceptionis inignore-exceptions: no retry, and no fallback either, since the fallback is declared forRemoteCallException. The exception reached the caller unchanged.
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 -
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-callsresults 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
CallNotPermittedExceptionat 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-statehad 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.
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 - thetimeout-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 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
CallNotPermittedExceptionfor each attempt. Add that exception to the retry'signore-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.
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.Spring Cloud and Microservices Tutorials
API Gateway with Spring Cloud GatewayClick to read tutorial: API Gateway with Spring Cloud Gateway Distributed Tracing with Micrometer and OpenTelemetryClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry Microservices with Spring: When and WhyClick to read tutorial: Microservices with Spring: When and Why Centralized Configuration with Spring Cloud ConfigClick to read tutorial: Centralized Configuration with Spring Cloud Config Service Discovery: Eureka vs KubernetesClick to read tutorial: Service Discovery: Eureka vs Kubernetes Inter-Service Communication: REST vs Messaging vs gRPCClick to read tutorial: Inter-Service Communication: REST vs Messaging vs gRPC Saga Pattern for Distributed TransactionsClick to read tutorial: Saga Pattern for Distributed Transactions
API Gateway with Spring Cloud GatewayClick to read tutorial: API Gateway with Spring Cloud Gateway Distributed Tracing with Micrometer and OpenTelemetryClick to read tutorial: Distributed Tracing with Micrometer and OpenTelemetry Microservices with Spring: When and WhyClick to read tutorial: Microservices with Spring: When and Why Centralized Configuration with Spring Cloud ConfigClick to read tutorial: Centralized Configuration with Spring Cloud Config Service Discovery: Eureka vs KubernetesClick to read tutorial: Service Discovery: Eureka vs Kubernetes Inter-Service Communication: REST vs Messaging vs gRPCClick to read tutorial: Inter-Service Communication: REST vs Messaging vs gRPC Saga Pattern for Distributed TransactionsClick to read tutorial: Saga Pattern for Distributed Transactions