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.

XML
<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 dependency is required because Resilience4j uses Spring AOP proxies to intercept annotated method calls. Without it, the annotations have no effect.

For Spring Boot 2.x projects, use resilience4j-spring-boot2 instead. The annotation API is identical; only the starter artifact name differs.

Circuit 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 CallNotPermittedException. The breaker waits for a configured duration before transitioning.
  • 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 @CircuitBreaker annotation to any Spring-managed bean method. The name attribute links the annotation to the YAML configuration, and fallbackMethod specifies the method to call when the breaker is open or a failure occurs.

Java
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. The sliding window counts the last N calls and opens the breaker when the failure rate exceeds the threshold.

YAML
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.

The fallback method must have the same return type as the original method and accept the original parameters plus a Throwable as the last argument. If the signature does not match, Spring throws an exception at startup.

Retry

The @Retry annotation automatically retries a failed method call a configured number of times before giving up. This is useful for transient failures such as network timeouts or temporary service unavailability.

Java
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());
    }
}
YAML
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 IOException and SocketTimeoutException trigger a retry; an InvalidPaymentException is never retried because retrying a validation error is pointless.

Use ignore-exceptions to exclude business exceptions that should not be retried. Retrying a "bad request" error wastes time and resources because the same input will produce the same failure.

Rate Limiter

The @RateLimiter annotation restricts how many calls a method can receive within a time window. Calls that exceed the limit either wait for a permit or fail immediately, depending on the configured timeout.

Java
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));
    }
}
YAML
resilience4j:
  ratelimiter:
    instances:
      emailLimiter:
        limit-for-period: 10
        limit-refresh-period: 1s
        timeout-duration: 0s

This allows 10 calls per second. With timeout-duration set to 0s, any call that exceeds the limit is rejected immediately rather than waiting for a permit. Setting it to a positive value (e.g. 2s) makes excess calls block until a permit becomes available or the timeout expires.

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.

Java
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.");
    }
}
YAML
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 is set to zero.

Thread Pool Bulkhead

Executes calls on a separate thread pool, fully isolating them from the main request threads. The method must return a CompletableFuture.

Java
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));
}
YAML
resilience4j:
  thread-pool-bulkhead:
    instances:
      reportBulkhead:
        max-thread-pool-size: 4
        core-thread-pool-size: 2
        queue-capacity: 10
        keep-alive-duration: 100ms
Use the semaphore bulkhead for most cases. The thread pool bulkhead adds overhead from context switching and is mainly useful when you need full thread isolation between services.

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.

Java
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.

YAML
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.

Be careful when combining Retry with CircuitBreaker. If Retry wraps CircuitBreaker (Retry is outermost), retries can keep the failure count climbing and push the breaker to OPEN faster. Ensure the retry count is low and backoff is sufficient.

Monitoring with Actuator

The Resilience4j Spring Boot starter automatically registers health indicators and Actuator endpoints. Expose them in application.yml to monitor circuit breaker states, retry metrics, and rate limiter activity.

YAML
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:

  • /actuator/circuitbreakers: lists all circuit breaker instances and their current state (CLOSED, OPEN, HALF_OPEN)
  • /actuator/circuitbreakers/{name}: details and event history for a specific breaker
  • /actuator/retries: lists all retry instances
  • /actuator/ratelimiters: rate limiter instances and available permits
  • /actuator/bulkheads: bulkhead instances and concurrent call counts
Prometheus Metrics

Add the Micrometer Prometheus registry to export Resilience4j metrics in a format that Prometheus can scrape.

XML
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Metrics are exposed at /actuator/prometheus and include counters and histograms for each Resilience4j component:

YAML
# 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
Use Grafana dashboards with these Prometheus metrics to visualize breaker state transitions, retry spikes, and rate limiter saturation in real time.

Configuration via YAML

Resilience4j supports a default/instance configuration model. Define shared defaults under configs.default and override specific settings per instance. This keeps configuration DRY across multiple service clients.

YAML
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-config and can override individual properties. The paymentService circuit breaker above inherits the aggressive profile (30% failure threshold, 5-call window) but overrides the wait duration to 60 seconds.

You can also use Spring profiles to swap configurations per environment:

YAML
# 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.

Always test your resilience configurations under realistic load. A sliding window that is too small or a failure threshold that is too low can cause the breaker to open prematurely and reject valid traffic.