Skip to content
Spring Boot sb microservices 3 min read

Circuit Breaker (Resilience4j)

A slow or failing dependency can drag down everything that calls it — threads pile up waiting on timeouts until the whole service stalls. A circuit breaker stops the bleeding: after enough failures it “opens” and fails fast, giving the downstream service room to recover. Spring Boot integrates Resilience4j for circuit breakers plus retry, rate limiting, bulkheads, and time limiters.

The problem and the states

A circuit breaker is a state machine wrapped around a remote call.

        failures exceed threshold
 CLOSED ───────────────────────────► OPEN
   ▲                                   │
   │ calls succeed                     │ wait duration elapses
   │                                   ▼
   └──────────────── HALF-OPEN ◄───────┘
         trial calls succeed
StateBehavior
CLOSEDCalls pass through; failures are counted
OPENCalls fail fast (fallback) without touching the dependency
HALF-OPENA few trial calls are allowed; success closes, failure re-opens

Dependency

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
<!-- needed for @CircuitBreaker / @Retry annotation support -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

@CircuitBreaker with a fallback

Annotate the method that makes the remote call and point it at a fallback with a matching signature plus a trailing Throwable.

@Service
@RequiredArgsConstructor
public class InventoryGateway {
    private final RestClient inventoryClient;

    @CircuitBreaker(name = "inventory", fallbackMethod = "stockFallback")
    public InventoryResponse stock(String sku) {
        return inventoryClient.get()
                .uri("http://inventory-service/inventory/{sku}", sku)
                .retrieve()
                .body(InventoryResponse.class);
    }

    private InventoryResponse stockFallback(String sku, Throwable ex) {
        // degrade gracefully instead of failing the whole request
        return new InventoryResponse(sku, 0, "UNKNOWN");
    }
}

When the inventory breaker is OPEN, stock(...) returns the fallback immediately without calling the network.

Warning: The fallback method must be in the same bean, have the same parameters plus an extra Throwable (or specific exception), and return the same type. A signature mismatch yields NoSuchMethodException at runtime.

Configuration

Resilience4j is configured per-instance under resilience4j.* in application.yml:

resilience4j:
  circuitbreaker:
    instances:
      inventory:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 10
        failure-rate-threshold: 50          # open at 50% failures
        wait-duration-in-open-state: 10s    # stay open 10s, then half-open
        permitted-number-of-calls-in-half-open-state: 3
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 80        # treat 80%+ slow calls as failures

Retry

Retry transient failures before giving up. Apply alongside the circuit breaker — the breaker counts the final outcome.

@Retry(name = "inventory", fallbackMethod = "stockFallback")
@CircuitBreaker(name = "inventory", fallbackMethod = "stockFallback")
public InventoryResponse stock(String sku) { ... }
resilience4j:
  retry:
    instances:
      inventory:
        max-attempts: 3
        wait-duration: 200ms
        retry-exceptions:
          - java.io.IOException
        ignore-exceptions:
          - com.acme.NotFoundException     # don't retry a 404

Tip: Only retry idempotent operations. Retrying a non-idempotent POST can double-charge a customer. See Inter-Service Communication for idempotency keys.

Rate limiter, bulkhead, time limiter

PatternAnnotationProtects against
Circuit breaker@CircuitBreakerA persistently failing dependency
Retry@RetryTransient blips
Rate limiter@RateLimiterExceeding a callee’s quota
Bulkhead@BulkheadOne dependency exhausting all threads
Time limiter@TimeLimiterCalls hanging indefinitely
resilience4j:
  ratelimiter:
    instances:
      inventory:
        limit-for-period: 50          # 50 calls...
        limit-refresh-period: 1s      # ...per second
        timeout-duration: 0
  bulkhead:
    instances:
      inventory:
        max-concurrent-calls: 20      # cap concurrency to this dependency
  timelimiter:
    instances:
      inventory:
        timeout-duration: 3s
        cancel-running-future: true

@TimeLimiter requires a non-blocking return type — wrap the call in a CompletableFuture:

@TimeLimiter(name = "inventory")
@CircuitBreaker(name = "inventory", fallbackMethod = "asyncFallback")
public CompletableFuture<InventoryResponse> stockAsync(String sku) {
    return CompletableFuture.supplyAsync(() -> stock(sku));
}

private CompletableFuture<InventoryResponse> asyncFallback(String sku, Throwable ex) {
    return CompletableFuture.completedFuture(new InventoryResponse(sku, 0, "UNKNOWN"));
}

Observability

Resilience4j publishes Micrometer metrics, so circuit state and call rates show up in Actuator and Prometheus. Expose the dedicated health indicator:

management:
  health:
    circuitbreakers:
      enabled: true
  endpoint:
    health:
      show-details: always
GET /actuator/health
"circuitBreakers": { "inventory": { "state": "CLOSED", "failureRate": "0.0%" } }

The gateway can also wrap routes directly with the CircuitBreaker filter — see API Gateway.

Last updated June 13, 2026
Was this helpful?