Have you ever had an external API or microservice go down unexpectedly, only to watch threads waiting on timeouts pile up until the whole system collapsed in a cascade? The circuit breaker pattern exists to stop exactly this kind of failure propagation.
Hystrix has been in maintenance mode since 2019 and is excluded from the dependencies in Spring Boot 3.x. Its successor, Resilience4j, has become the de facto standard. This article walks through everything hands-on, from adding the dependency to implementing @CircuitBreaker, @Retry, and @RateLimiter.
Why You Need a Circuit Breaker
When an external API stops responding, the calling thread keeps waiting until it times out. As concurrent requests increase, the thread pool becomes exhausted, dragging down unrelated features and bringing the whole system down. This is the classic pattern of cascading failure.
A circuit breaker works just like its namesake: once failures start climbing, it automatically trips and returns a fallback. There are three states.
- CLOSED: Normal operation. Transitions to OPEN when the failure rate exceeds the threshold
- OPEN: Immediately blocks all requests and returns the fallback
- HALF_OPEN: After a set period, lets a few requests through to check for recovery. Transitions to CLOSED on success, or back to OPEN on failure
Adding the Dependency
For Spring Boot 3.x (Jakarta EE), use resilience4j-spring-boot3. Since the annotations rely on AOP, spring-boot-starter-aop is also required.
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. Check GitHub Releases for the latest version.
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 on Spring Boot 2.x, the artifact is resilience4j-spring-boot2 instead. Be careful not to mix them up. The Jakarta EE based resilience4j-spring-boot3 is expected to keep working on Spring Boot 4 as well, but once it reaches GA, confirm the supported version in the official documentation and release notes.
Basic @CircuitBreaker Implementation
All it takes is adding @CircuitBreaker to a service method. The method specified in fallbackMethod must have the same parameters as the original method, plus a trailing Throwable.
@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());
// 独自実装が必要: new Product() など空オブジェクトやキャッシュ値を返す
return new Product();
}
}
Design your fallback to return a response that keeps the service partially functional, 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 example brings together the full configuration, including @Retry and @RateLimiter. Each annotation is covered in detail in the sections that follow.
resilience4j:
circuitbreaker:
instances:
productApi:
failureRateThreshold: 50 # 失敗率50%でOPENに遷移
waitDurationInOpenState: 10s # OPENのまま10秒待つ
slidingWindowSize: 10 # 直近10リクエストを評価
permittedNumberOfCallsInHalfOpenState: 3
minimumNumberOfCalls: 5 # 最低5回の呼び出し後に評価開始
retry:
instances:
productApi:
maxAttempts: 3
waitDuration: 500ms
ratelimiter:
instances:
productApi:
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 0
permittedNumberOfCallsInHalfOpenState: 3 is the number of trial calls allowed in the HALF_OPEN state. The results of these three calls determine whether the breaker moves to CLOSED or OPEN. Setting minimumNumberOfCalls prevents the breaker from mistakenly tripping to OPEN based on the handful of requests received right after startup.
Implementing Retries with @Retry
Transient network errors can be handled with retries. When combined with @CircuitBreaker, the Resilience4j Spring Boot starter applies the CircuitBreaker outside the Retry by default. In other words, the final failure after all retries are exhausted is what gets 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 order can be changed via the global settings resilience4j.circuitbreaker.circuit-breaker-aspect-order and resilience4j.retry.retry-aspect-order, but the defaults cover most cases.
Implementing Rate Limiting with @RateLimiter
This is useful for respecting external API call limits and protecting your own service from overload. When the limit is exceeded, a RequestNotPermittedException is thrown, which you handle 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, "レート制限中です。しばらく待ってから再試行してください。");
}
Limiting Concurrent Executions with @Bulkhead
A Bulkhead limits the number of threads that can execute concurrently, preventing one external call from consuming all threads and affecting other features. Where the CircuitBreaker stops the propagation of failures, the Bulkhead stops the propagation of resource exhaustion.
@Bulkhead(name = "productApi", fallbackMethod = "bulkheadFallback")
public Product getProductWithBulkhead(Long id) {
return restTemplate.getForObject(
"https://api.example.com/products/" + id, Product.class);
}
public Product bulkheadFallback(Long id, Throwable t) {
return new Product();
}
resilience4j:
bulkhead:
instances:
productApi:
maxConcurrentCalls: 10
maxWaitDuration: 100ms
maxConcurrentCalls sets the upper limit on concurrent executions, and maxWaitDuration sets how long to wait to acquire a slot. A thread pool variant (thread-pool-bulkhead) is also available, which runs calls in a separate pool for stronger isolation.
Setting Timeouts with @TimeLimiter
TimeLimiter provides timeout control for asynchronous operations such as CompletableFuture. It cannot be applied directly to synchronous calls like RestTemplate, so you need to wrap the return value in a CompletableFuture.
@TimeLimiter(name = "productApi", fallbackMethod = "timeoutFallback")
@CircuitBreaker(name = "productApi", fallbackMethod = "timeoutFallback")
public CompletableFuture<Product> getProductAsync(Long id) {
return CompletableFuture.supplyAsync(() ->
restTemplate.getForObject(
"https://api.example.com/products/" + id, Product.class));
}
public CompletableFuture<Product> timeoutFallback(Long id, Throwable t) {
return CompletableFuture.completedFuture(new Product());
}
resilience4j:
timelimiter:
instances:
productApi:
timeoutDuration: 2s
cancelRunningFuture: true
With cancelRunningFuture: true, the running Future is cancelled on timeout. When a TimeoutException occurs, control passes to the fallback.
Module Comparison: Which One to Use When
| Module | Main Purpose | Primary Failure Mode | Sync/Async |
|---|---|---|---|
| CircuitBreaker | Stop cascading failures | Failure rate/response time exceeds threshold | Both |
| Retry | Automatically retry transient errors | Exception thrown | Both |
| RateLimiter | Limit call frequency | Limit exceeded | Both |
| Bulkhead | Isolate concurrent executions | Thread exhaustion | Both |
| TimeLimiter | Time out slow responses | Response delay | Async only |
The default aspect order from the outside in is Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead. Keeping this order in mind when combining modules ensures the behavior matches your expectations.
RateLimiter and Bulkhead both guard against “calling too much,” but the former limits the number of calls per unit of time, while the latter limits the number of calls running simultaneously. A simple rule of thumb: use RateLimiter to smooth out momentary spikes, and Bulkhead to prevent thread exhaustion from long-running operations.
Checking Circuit Breaker State with Actuator
With spring-boot-starter-actuator added and the following configuration in place, you can view the state and statistics of every instance at /actuator/circuitbreakers.
management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers,circuitbreakerevents
endpoint:
health:
show-details: always
Combined with Micrometer, metrics such as resilience4j.circuitbreaker.state can be collected via Prometheus. In production, building a dashboard in Grafana or a similar tool lets you monitor state transitions at a glance.
Verification: Simulating Failures and Observing State Transitions
The most reliable way to observe circuit breaker state transitions locally is to expose a controller endpoint that calls ProductService.getProduct() and point the external API URL at 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 host that does not exist (for example, http://localhost:9999), and every request will produce a connection error.
for i in $(seq 1 10); do curl -s http://localhost:8080/products/1; done
Once the call count exceeds minimumNumberOfCalls and the failure rate reaches the threshold, the breaker transitions to OPEN. Check the state via Actuator.
curl http://localhost:8080/actuator/circuitbreakers
# => "state": "OPEN" が確認できる
After the time set in waitDurationInOpenState elapses, the breaker transitions to HALF_OPEN and decides on recovery based on permittedNumberOfCallsInHalfOpenState requests. If they succeed, the state returns to "state": "CLOSED".
Common Pitfalls
Forgetting to add spring-boot-starter-aop
This is the most common one. Without AOP, the annotations simply do not work. Always rebuild after adding the dependency.
Calls from within the same class do not work
Spring AOP intercepts through proxies, so calling this.getProduct() from within the same class bypasses AOP entirely. Split the service into a separate class and inject it via DI.
Mismatched fallbackMethod signature
If the parameter types or count differ, you get a NoSuchMethodException at runtime. Stick to the format of appending Throwable (or the specific exception class you want to handle) at the end. When the fallback is not being invoked, this is the first thing to check. Also remember to make the fallback public, since a private method cannot be called through the AOP proxy.
Mixing up resilience4j-spring-boot2 and 3
For Spring Boot 3.x (Jakarta EE), always use resilience4j-spring-boot3. If you add the 2.x starter, dependency resolution may succeed but things may silently fail to work at runtime.
Summary
With Resilience4j, you can combine @CircuitBreaker, @Retry, and @RateLimiter to write implementations that are resilient to external API failures with minimal effort. A good approach is to start with just @CircuitBreaker, verify its behavior via Actuator, and then add @Retry as needed.
For the overall design of error handling, reading Exception Handling in REST APIs alongside this article will help you build a more practical implementation. If you want to combine this with asynchronous processing, see Asynchronous Processing in Spring Boot. To strengthen fault tolerance with an event-driven architecture, consider Integrating with Kafka as well.
Related Articles
To raise the overall quality of your production operations, the following articles are also worth a look.
- Tuning the HikariCP Connection Pool: Prevent outages caused by DB connection exhaustion
- Graceful Shutdown and Zero-Downtime Deployment: Eliminate dropped requests during deployment
- Running GlobalExceptionHandler in Production: Get your exception handling in order
- Loose Coupling with ApplicationEvent: Decouple dependencies between services