Have you ever experienced a situation where an external API or microservice suddenly went down, causing threads waiting on timeouts to pile up and the system to fail in a cascading manner? The Circuit Breaker pattern is what prevents this kind of fault propagation.

Hystrix entered maintenance mode in 2019 and has been removed from Spring Boot 3.x dependencies. Resilience4j has become the de facto successor standard. This article walks through 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 doesn’t respond, the calling threads keep waiting until they time out. As concurrent requests increase, the thread pool gets exhausted, dragging down unrelated features along with it — this is a common pattern of cascading failure.

A circuit breaker, as the name suggests, operates as a “breaker” that automatically cuts off requests and returns a fallback when failures increase. It has three states:

  • CLOSED: Normal operation. Transitions to OPEN when the failure rate exceeds a threshold
  • OPEN: Immediately blocks all requests and returns a fallback
  • HALF_OPEN: After a certain period, allows a few requests through to check 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 work 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 Resilience4j versions are managed by Spring Boot’s dependency management, you can omit the version specification. Check the latest version on GitHub Releases.

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’re using Spring Boot 2.x, you’ll need resilience4j-spring-boot2. Take care not to mix them up.

Basic @CircuitBreaker Implementation

Just adding @CircuitBreaker to a service method is enough to make it work. The method specified in fallbackMethod must have the same arguments as the original method, with Throwable appended at the end.

@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 needed: return an empty object like new Product() or a cached value
        return new Product();
    }
}

Design your fallbacks to return responses that keep the service partially functional, such as cached values, empty objects, or default values.

For HTTP client implementations, see How to use RestTemplate and WebClient as well.

Configuring Parameters in application.yml

Below is a comprehensive configuration example including @Retry and @RateLimiter. Each annotation is explained in detail in subsequent sections.

resilience4j:
  circuitbreaker:
    instances:
      productApi:
        failureRateThreshold: 50          # Transition to OPEN at 50% failure rate
        waitDurationInOpenState: 10s       # Stay OPEN for 10 seconds
        slidingWindowSize: 10             # Evaluate the last 10 requests
        permittedNumberOfCallsInHalfOpenState: 3
        minimumNumberOfCalls: 5           # Start evaluation 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 attempts made in the HALF_OPEN state. The results of these 3 calls determine whether to transition to CLOSED or OPEN. Setting minimumNumberOfCalls prevents erroneous transitions 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 combined with @CircuitBreaker, the Resilience4j Spring Boot starter applies CircuitBreaker outside of Retry by default. This means that the final failure after retries gets added to the circuit breaker’s failure count.

@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 work for most cases.

Implementing Rate Limiting with @RateLimiter

Limiting Concurrent Execution with @Bulkhead

Bulkhead limits the number of concurrently executable threads, preventing a single external call from consuming all threads and affecting other features. While CircuitBreaker prevents “failure propagation,” think of Bulkhead as preventing “resource exhaustion propagation.”

@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 for concurrent executions, and maxWaitDuration sets the wait time for acquiring a slot. A thread-pool type (thread-pool-bulkhead) is also available, which provides a higher level of isolation by executing in a separate pool.

Setting Timeouts with @TimeLimiter

TimeLimiter provides timeout control for asynchronous processes 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 canceled on timeout. When a TimeoutException occurs, execution flows to the fallback.

Module Comparison: When to Use Which

ModuleMain PurposeMain Failure ModeSync/Async
CircuitBreakerCut off cascading failuresFailure rate / response time exceeds thresholdBoth
RetryAutomatic retry of transient errorsException occurrenceBoth
RateLimiterLimit call frequencyLimit exceededBoth
BulkheadIsolate concurrent executionsThread exhaustionBoth
TimeLimiterResponse time timeoutSlow responseAsync only

The default aspect application order from outermost to innermost is Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead. Keeping this order in mind when combining them helps ensure the expected behavior.

It can be used for restricting external API calls or protecting your 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, "Rate limit in effect. Please wait and try again.");
}

Checking Circuit Breaker State with Actuator

After adding spring-boot-starter-actuator, the following configuration enables /actuator/circuitbreakers to show the state and statistics of all instances.

management:
  endpoints:
    web:
      exposure:
        include: health,info,circuitbreakers,circuitbreakerevents
  endpoint:
    health:
      show-details: always

Verifying Behavior: Simulating Failures and Observing State Transitions

To verify 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);
    }
}

Setting the external API URL in application.yml to a non-existent host (e.g., http://localhost:9999) will cause a connection error on every request.

for i in $(seq 1 10); do curl -s http://localhost:8080/products/1; done

Once the failure rate reaches the threshold after exceeding minimumNumberOfCalls, it transitions to OPEN. Let’s check the state with Actuator.

curl http://localhost:8080/actuator/circuitbreakers
# => You can confirm "state": "OPEN"

After the time set in waitDurationInOpenState elapses, it transitions to HALF_OPEN, and recovery is determined based on permittedNumberOfCallsInHalfOpenState requests. On success, it returns to "state": "CLOSED".

Common Pitfalls

Forgetting to add spring-boot-starter-aop This is the most common mistake. Without AOP, the annotations won’t work at all. Always rebuild after adding the dependency.

Calls from within the same class don’t work Since Spring AOP intercepts via proxies, calling this.getProduct() within the same class won’t trigger AOP. Separate the service class into a different class and inject it via DI.

Mismatched fallbackMethod signature If the type or number of arguments differs, a NoSuchMethodException will occur at runtime. Stick to the format that appends Throwable (or a concrete subclass) at the end.

Confusing 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 pass but it may not work at runtime.

Summary

With Resilience4j, you can write implementations that are resilient to external API failures by combining @CircuitBreaker, @Retry, and @RateLimiter in a simple way. We recommend starting with just @CircuitBreaker, verifying behavior with Actuator, and adding @Retry as needed.

For the overall design of error handling, read Exception handling for REST APIs together with this article for more practical implementations. If you want to combine it with asynchronous processing, also check out Spring Boot async processing. If you want to strengthen failure countermeasures with an event-driven architecture, consider Integration with Kafka.

Frequently Asked Questions (FAQ)

Q. Why isn’t the @CircuitBreaker fallbackMethod being called?

A. It’s likely that the fallbackMethod signature doesn’t match the original method. The argument types and counts must be identical, with Throwable (or a concrete exception class you want to handle) appended at the end. Also, private methods within the same class can’t be called via the AOP proxy, so make sure they’re public.

Q. Can I use Resilience4j with Spring Boot 4?

A. As of May 2026, Spring Boot 4 hasn’t reached official GA yet, but Resilience4j is expected to work as-is with the Jakarta EE-based resilience4j-spring-boot3. After Spring Boot 4 GA, check the official documentation and release notes for supported versions.

Q. When I apply both @CircuitBreaker and @Retry, are the retries counted toward the circuit breaker?

A. By default, CircuitBreaker is applied outside Retry, so only the single final failure after Retry exhausts is added to the CircuitBreaker’s failure count. Intermediate retry failures are not visible to the CircuitBreaker.

Q. What’s the difference between Bulkhead and RateLimiter?

A. RateLimiter limits the “number of calls per unit time,” while Bulkhead limits the “number of calls running concurrently.” Use RateLimiter when you want to suppress instantaneous spikes, and Bulkhead when you want to prevent thread exhaustion from long-running processes.

Q. How can I monitor the circuit breaker state as metrics?

A. By combining spring-boot-starter-actuator with Micrometer, you can retrieve metrics such as resilience4j.circuitbreaker.state via Prometheus. For production operations, we recommend creating dashboards using tools like Grafana.

To improve the overall quality of production operations, also refer to the following articles: