Resilience4j: Circuit Breaker, Retry & Rate Limiter
Resilience4j is a lightweight fault-tolerance library designed for Java and Spring Boot. It provides five core patterns (Circuit Breaker, Retry, Rate Limiter, Bulkhead, and TimeLimiter) as decorators that you can stack on any method call. With the Spring Boot starter, you simply annotate methods and configure behavior in application.yml.
Contents
Dependencies & Setup
Add the Resilience4j Spring Boot 3 starter along with Spring Boot Actuator. The starter auto-configures all Resilience4j modules and registers AOP aspects that process the annotations.
<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-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
The spring-boot-starter-aop
resilience4j-spring-boot2Circuit Breaker
A Circuit Breaker prevents an application from repeatedly calling a failing service. It tracks recent call outcomes and transitions between three states:
- CLOSED: calls pass through normally. The breaker monitors failure rates using a sliding window.
- OPEN: all calls are immediately rejected with a
. The breaker waits for a configured duration before transitioning.CallNotPermittedException - HALF_OPEN: a limited number of trial calls are permitted. If enough succeed, the breaker returns to CLOSED; otherwise it goes back to OPEN.
Apply the @CircuitBreakernamefallbackMethod
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final InventoryClient inventoryClient;
public OrderService(InventoryClient inventoryClient) {
this.inventoryClient = inventoryClient;
}
@CircuitBreaker(name = "inventoryService", fallbackMethod = "fallbackCheckStock")
public boolean checkStock(String productId) {
return inventoryClient.isInStock(productId);
}
// Fallback must have the same return type and an extra Throwable parameter
private boolean fallbackCheckStock(String productId, Throwable t) {
// Return a safe default when the inventory service is unavailable
return false;
}
}
Configure the circuit breaker instance in application.yml
resilience4j:
circuitbreaker:
instances:
inventoryService:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
minimum-number-of-calls: 5
record-exceptions:
- java.io.IOException
- java.net.SocketTimeoutException
With this configuration, the breaker evaluates the last 10 calls. If 50% or more fail (and at least 5 calls have been made), it opens for 10 seconds. During HALF_OPEN, 3 trial calls are allowed before deciding whether to close or reopen.
ThrowableRetry
The @Retry
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final PaymentGateway paymentGateway;
public PaymentService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
@Retry(name = "paymentRetry", fallbackMethod = "fallbackProcessPayment")
public PaymentResult processPayment(PaymentRequest request) {
return paymentGateway.charge(request);
}
private PaymentResult fallbackProcessPayment(PaymentRequest request, Throwable t) {
return PaymentResult.failed("Payment service temporarily unavailable: " + t.getMessage());
}
}
resilience4j:
retry:
instances:
paymentRetry:
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
- java.net.SocketTimeoutException
ignore-exceptions:
- com.example.InvalidPaymentException
This configuration retries up to 3 times with exponential backoff: the first retry waits 500ms, the second waits 1000ms, and the third waits 2000ms. Only IOExceptionSocketTimeoutExceptionInvalidPaymentException
ignore-exceptionsRate Limiter
The @RateLimiter
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final EmailClient emailClient;
public NotificationService(EmailClient emailClient) {
this.emailClient = emailClient;
}
@RateLimiter(name = "emailLimiter", fallbackMethod = "fallbackSendEmail")
public void sendEmail(String to, String subject, String body) {
emailClient.send(to, subject, body);
}
private void fallbackSendEmail(String to, String subject, String body, Throwable t) {
// Queue the email for later delivery
emailQueue.enqueue(new EmailMessage(to, subject, body));
}
}
resilience4j:
ratelimiter:
instances:
emailLimiter:
limit-for-period: 10
limit-refresh-period: 1s
timeout-duration: 0s
This allows 10 calls per second. With timeout-duration0s2s
Bulkhead
A Bulkhead limits the number of concurrent calls to a method, preventing one slow service from consuming all available threads and starving other parts of the application. Resilience4j provides two bulkhead types.
Semaphore Bulkhead (default)
Uses a semaphore to cap concurrent executions on the calling thread. No thread pool is involved.
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
@Service
public class ReportService {
@Bulkhead(name = "reportBulkhead", fallbackMethod = "fallbackGenerateReport")
public Report generateReport(ReportRequest request) {
// Expensive database and PDF generation logic
return reportEngine.generate(request);
}
private Report fallbackGenerateReport(ReportRequest request, Throwable t) {
throw new ServiceBusyException("Report generation is at capacity. Try again shortly.");
}
}
resilience4j:
bulkhead:
instances:
reportBulkhead:
max-concurrent-calls: 5
max-wait-duration: 0ms
At most 5 report generation requests run concurrently. Additional requests are immediately rejected because max-wait-duration
Thread Pool Bulkhead
Executes calls on a separate thread pool, fully isolating them from the main request threads. The method must return a CompletableFuture
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import io.github.resilience4j.bulkhead.annotation.Bulkhead.Type;
@Bulkhead(name = "reportBulkhead", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<Report> generateReportAsync(ReportRequest request) {
return CompletableFuture.supplyAsync(() -> reportEngine.generate(request));
}
resilience4j:
thread-pool-bulkhead:
instances:
reportBulkhead:
max-thread-pool-size: 4
core-thread-pool-size: 2
queue-capacity: 10
keep-alive-duration: 100ms
Combining Patterns
Resilience4j annotations can be stacked on the same method to compose multiple patterns. The order in which they execute depends on the aspect order, which you control in YAML or via @Order
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
@Service
public class CatalogService {
private final ExternalCatalogApi catalogApi;
public CatalogService(ExternalCatalogApi catalogApi) {
this.catalogApi = catalogApi;
}
@CircuitBreaker(name = "catalogBreaker", fallbackMethod = "fallbackSearch")
@Retry(name = "catalogRetry")
@RateLimiter(name = "catalogLimiter")
@Bulkhead(name = "catalogBulkhead")
public List<Product> searchProducts(String query) {
return catalogApi.search(query);
}
private List<Product> fallbackSearch(String query, Throwable t) {
return Collections.emptyList();
}
}
Configure the aspect order to control which pattern wraps which. A higher order value means the aspect executes closer to the actual method call (innermost). The default order is: Retry → CircuitBreaker → RateLimiter → Bulkhead.
resilience4j:
circuitbreaker:
circuit-breaker-aspect-order: 1
retry:
retry-aspect-order: 2
ratelimiter:
rate-limiter-aspect-order: 3
bulkhead:
bulkhead-aspect-order: 4
With this ordering, the call passes through Bulkhead first (outermost, order 4), then RateLimiter, then Retry, and finally CircuitBreaker (innermost, order 1). This means the Retry wraps the CircuitBreaker, if the breaker rejects a call, the Retry can attempt again once the breaker transitions to HALF_OPEN.
Monitoring with Actuator
The Resilience4j Spring Boot starter automatically registers health indicators and Actuator endpoints. Expose them in application.yml
management:
endpoints:
web:
exposure:
include: health, circuitbreakers, retries, ratelimiters, bulkheads
health:
circuitbreakers:
enabled: true
endpoint:
health:
show-details: always
resilience4j:
circuitbreaker:
configs:
default:
register-health-indicator: true
Once configured, the following Actuator endpoints are available:
: lists all circuit breaker instances and their current state (CLOSED, OPEN, HALF_OPEN)/actuator/circuitbreakers : details and event history for a specific breaker/actuator/circuitbreakers/{name} : lists all retry instances/actuator/retries : rate limiter instances and available permits/actuator/ratelimiters : bulkhead instances and concurrent call counts/actuator/bulkheads
Prometheus Metrics
Add the Micrometer Prometheus registry to export Resilience4j metrics in a format that Prometheus can scrape.
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Metrics are exposed at /actuator/prometheus
# Key Prometheus metrics:
# resilience4j_circuitbreaker_state — current state (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
# resilience4j_circuitbreaker_calls_seconds — call duration histogram
# resilience4j_circuitbreaker_failure_rate — current failure rate percentage
# resilience4j_retry_calls_total — total calls by result (successful, failed, retry)
# resilience4j_ratelimiter_available_permissions — permits available in the current period
# resilience4j_bulkhead_available_concurrent_calls — remaining concurrent call capacity
Configuration via YAML
Resilience4j supports a default/instance configuration model. Define shared defaults under configs.default
resilience4j:
circuitbreaker:
configs:
default:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
minimum-number-of-calls: 5
register-health-indicator: true
aggressive:
failure-rate-threshold: 30
wait-duration-in-open-state: 30s
sliding-window-size: 5
instances:
inventoryService:
base-config: default
paymentService:
base-config: aggressive
wait-duration-in-open-state: 60s
retry:
configs:
default:
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
instances:
paymentRetry:
base-config: default
max-attempts: 5
catalogRetry:
base-config: default
ratelimiter:
configs:
default:
limit-for-period: 50
limit-refresh-period: 1s
timeout-duration: 0s
instances:
emailLimiter:
base-config: default
limit-for-period: 10
catalogLimiter:
base-config: default
limit-for-period: 100
bulkhead:
configs:
default:
max-concurrent-calls: 10
max-wait-duration: 0ms
instances:
reportBulkhead:
base-config: default
max-concurrent-calls: 5
catalogBulkhead:
base-config: default
max-concurrent-calls: 15
Each instance references a base config with base-configpaymentServiceaggressive
You can also use Spring profiles to swap configurations per environment:
# application-prod.yml
resilience4j:
circuitbreaker:
instances:
inventoryService:
wait-duration-in-open-state: 30s
failure-rate-threshold: 60
In production, the breaker tolerates a higher failure rate (60%) and waits longer before retrying, avoiding unnecessary pressure on an already struggling service.