Have you ever experienced a situation where an external API or microservice suddenly goes down, causing threads to pile up waiting for timeouts and triggering a cascading system failure? The circuit breaker pattern is designed to prevent exactly this kind of fault propagation.
Hystrix entered maintenance mode in 2019 and has been removed from Spring Boot 3.x dependencies. Resilience4j has emerged as the de facto standard successor. This article covers everything from adding dependencies to implementing @CircuitBreaker, @Retry, and @RateLimiter, in a hands-on format.
Why You Need a Circuit Breaker
When an external API stops responding, the calling thread keeps waiting until a timeout occurs. As concurrent requests increase, the thread pool becomes exhausted, dragging down unrelated features and causing a full system outage — this is the classic fault cascade pattern.
A circuit breaker acts just like its namesake: it automatically trips when failures accumulate and returns a fallback response. There are three states:
- CLOSED: Normal operation. Transitions to OPEN when the failure rate exceeds the threshold.
- OPEN: All requests are immediately rejected and a fallback is returned.
- HALF_OPEN: After a waiting period, a limited number of requests are allowed through to check for recovery. Transitions to CLOSED on success, or back to OPEN on failure.
Adding Dependencies
For Spring Boot 3.x (Jakarta EE), use resilience4j-spring-boot3. Since annotations are driven via AOP, spring-boot-starter-aop is also required.
For Gradle
dependencies {
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
If the Resilience4j version is managed by Spring Boot’s dependency management, you can omit the version specifier. Check GitHub Releases for the latest version.
For Maven
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
If you are using Spring Boot 2.x, use resilience4j-spring-boot2 instead. Be careful not to confuse the two.
Basic Implementation with @CircuitBreaker
Simply annotate a service method with @CircuitBreaker and it works. The method specified in fallbackMethod must have the same signature as the original method, with a Throwable appended as the last parameter.
@Service
public class ProductService {
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
private final RestTemplate restTemplate;
public ProductService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@CircuitBreaker(name = "productApi", fallbackMethod = "getProductFallback")
public Product getProduct(Long id) {
return restTemplate.getForObject(
"https://api.example.com/products/" + id, Product.class);
}
public Product getProductFallback(Long id, Throwable t) {
log.warn("Circuit breaker triggered: {}", t.getMessage());
// Custom implementation required: return an empty object like new Product(), or a cached value
return new Product();
}
}
Design your fallback to return a response that partially maintains service availability — such as a cached value, an empty object, or a default value.
For HTTP client implementation details, see How to Use RestTemplate and WebClient.
Configuring Parameters in application.yml
The following is a complete example configuration including @Retry and @RateLimiter. Each annotation is explained in detail in the sections below.
resilience4j:
circuitbreaker:
instances:
productApi:
failureRateThreshold: 50 # Transition to OPEN when failure rate reaches 50%
waitDurationInOpenState: 10s # Stay in OPEN state for 10 seconds
slidingWindowSize: 10 # Evaluate the last 10 requests
permittedNumberOfCallsInHalfOpenState: 3
minimumNumberOfCalls: 5 # Start evaluating after at least 5 calls
retry:
instances:
productApi:
maxAttempts: 3
waitDuration: 500ms
ratelimiter:
instances:
productApi:
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 0
permittedNumberOfCallsInHalfOpenState: 3 is the number of calls allowed in the HALF_OPEN state. The outcome of these 3 calls determines whether to transition to CLOSED or OPEN. Setting minimumNumberOfCalls prevents an unintended transition to OPEN due to a small number of requests right after startup.
Implementing Retry with @Retry
Transient network errors can be handled with retries. When combining @CircuitBreaker with @Retry, the Resilience4j Spring Boot starter applies the CircuitBreaker on the outside of Retry by default. This means the final failure after all retry attempts are exhausted is counted as a failure by the circuit breaker.
@CircuitBreaker(name = "productApi", fallbackMethod = "getProductFallback")
@Retry(name = "productApi")
public Product getProduct(Long id) {
return restTemplate.getForObject(
"https://api.example.com/products/" + id, Product.class);
}
The aspect application order can be changed via the global settings resilience4j.circuitbreaker.circuit-breaker-aspect-order and resilience4j.retry.retry-aspect-order, but the defaults handle most use cases.
Implementing Rate Limiting with @RateLimiter
This is useful for enforcing external API call limits or protecting your own service from overload. When the limit is exceeded, a RequestNotPermittedException is thrown, which should be handled in the fallback.
@RateLimiter(name = "productApi", fallbackMethod = "rateLimitFallback")
public Product getProductWithRateLimit(Long id) {
return restTemplate.getForObject(
"https://api.example.com/products/" + id, Product.class);
}
public Product rateLimitFallback(Long id, RequestNotPermittedException e) {
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS, "Rate limit exceeded. Please wait a moment and try again.");
}
Monitoring Circuit Breaker State with Actuator
After adding spring-boot-starter-actuator, add the following configuration to view the state and statistics of all instances at /actuator/circuitbreakers.
management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers,circuitbreakerevents
endpoint:
health:
show-details: always
Verification: Simulating Failures and Observing State Transitions
To observe circuit breaker state transitions locally, the most reliable approach is to set up a controller endpoint that calls ProductService.getProduct() and point the external API URL to an unreachable host.
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.getProduct(id);
}
}
Set the external API URL in application.yml to a non-existent host (e.g., http://localhost:9999) so that every request triggers a connection error.
for i in $(seq 1 10); do curl -s http://localhost:8080/products/1; done
Once the failure rate exceeds the threshold after surpassing minimumNumberOfCalls, the circuit transitions to OPEN. Verify the state using Actuator:
curl http://localhost:8080/actuator/circuitbreakers
# => "state": "OPEN" should be visible
After the duration configured by waitDurationInOpenState elapses, the circuit transitions to HALF_OPEN and uses permittedNumberOfCallsInHalfOpenState requests to assess recovery. On success, it returns to "state": "CLOSED".
Common Pitfalls
Forgetting to add spring-boot-starter-aop
This is the most common mistake. Without AOP, annotations simply do not work. Always trigger a clean rebuild after adding the dependency.
Calls within the same class are not intercepted
Spring AOP intercepts via a proxy, so calling this.getProduct() from within the same class bypasses AOP entirely. Extract the service into a separate class and inject it via DI.
Mismatched fallbackMethod signature
If the parameter types or count do not match, a NoSuchMethodException will be thrown at runtime. Always append Throwable (or a concrete subclass) as the last parameter.
Confusing resilience4j-spring-boot2 with 3
For Spring Boot 3.x (Jakarta EE), always use resilience4j-spring-boot3. Using the 2.x starter may resolve dependencies successfully but fail silently at runtime.
Summary
With Resilience4j, you can combine @CircuitBreaker, @Retry, and @RateLimiter to build resilient external API integrations with minimal code. The recommended approach is to start with just @CircuitBreaker, verify behavior via Actuator, and add @Retry as needed.
For the overall error handling design, reading Exception Handling in REST APIs alongside this article will give you a more practical implementation. If you want to combine this with asynchronous processing, see Asynchronous Processing in Spring Boot. For strengthening fault tolerance in event-driven architectures, consider also looking at Integration with Kafka.