Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 077 / 100

Circuit breakers with Resilience4j in Java & Spring Boot

Stop hammering a dependency that is already down.

Expona Admin6 min read

When Dependencies Fail, Your System Shouldn't

We've all been there: a downstream service starts timing out, your thread pool exhausts, and suddenly your entire application becomes unresponsive. Circuit breakers with Resilience4j in Java & Spring Boot solve this by stopping cascading failures before they cascade. Instead of hammering a struggling dependency with requests, a circuit breaker detects trouble and switches to fallback behavior—buying time for the dependency to recover while keeping your application responsive.

On Day 77 of the 100-day Java, Spring Framework & Spring Boot series, we're implementing circuit breakers the way production systems do: with state transitions, configurable thresholds, and real-time observability into what's happening. By the end of this article, you'll understand how to wrap your external calls with Resilience4j, expose circuit breaker metrics to your dashboards, and handle failure modes gracefully.

The Three States: Closed, Open, and Half-Open

A circuit breaker is fundamentally a state machine. Think of it as an electrical circuit breaker in your home: when everything is normal, current flows freely (closed). When a fault is detected, it trips (opens) to prevent damage. When you think it's safe, you reset it and monitor carefully (half-open).

Closed state: All requests pass through. The circuit breaker counts failures silently. Once failures exceed your threshold—say, 50% of the last 100 requests fail, or 5 consecutive failures occur—it transitions to open.

Open state: Requests fail immediately without calling the dependency. This is the critical protection: instead of waiting for a timeout, clients get a fast failure and can invoke a fallback. Newly opened circuits have a configurable wait duration (e.g., 30 seconds) before attempting recovery.

Half-open state: After the wait duration expires, the circuit allows a small number of probe requests (e.g., 3 calls) to test if the dependency has recovered. If these succeed, it transitions back to closed. If they fail, it reopens and resets the wait timer.

This design prevents the thundering herd problem: you're not bombarding a recovering service with thousands of requests all at once.

Closed → Open After a Failure Threshold

Resilience4j watches your calls in the closed state and transitions to open once failure rates breach your configured threshold. The decision happens based on metrics collected from a sliding window of recent calls.

Here's a practical setup with Spring Boot:

java
@Configuration
public class ResilienceConfig {

    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        return CircuitBreakerRegistry.ofDefaults();
    }

    @Bean
    public CircuitBreaker paymentServiceBreaker(CircuitBreakerRegistry registry) {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            .failureRateThreshold(50.0f)
            .slowCallRateThreshold(50.0f)
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .permittedNumberOfCallsInHalfOpenState(3)
            .minimumNumberOfCalls(10)
            .slidingWindowType(SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(100)
            .build();
        
        return registry.circuitBreaker("payment-service", config);
    }
}

The configuration above means: if 50% of the last 100 calls fail (or are slow), open the circuit. Stay open for 30 seconds, then allow 3 test calls. Require at least 10 calls before making any decision.

Now apply it to your service:

java
@Service
public class PaymentService {
    private final CircuitBreaker paymentServiceBreaker;
    private final RestTemplate restTemplate;

    public PaymentService(CircuitBreaker paymentServiceBreaker, 
                         RestTemplate restTemplate) {
        this.paymentServiceBreaker = paymentServiceBreaker;
        this.restTemplate = restTemplate;
    }

    public PaymentResult processPayment(String orderId, BigDecimal amount) {
        return paymentServiceBreaker.executeSupplier(() -> 
            restTemplate.postForObject(
                "https://payment-api.internal/charge",
                new ChargeRequest(orderId, amount),
                PaymentResult.class
            )
        );
    }
}

When the failure rate hits 50%, the next call to processPayment will see the circuit open and throw a CallNotPermittedException. You catch that and return a fallback, as we'll see next.

Open: Fail Fast with a Fallback

An open circuit doesn't crash your application; it signals clearly that the dependency is unavailable. Your code should handle this gracefully by invoking a fallback strategy.

The most common fallback patterns are:

  • Default value: Return a safe default (e.g., "payment processing is delayed, we'll retry later").
  • Cache: Return the last successful response if available.
  • Async queue: Enqueue the request for later processing and return a pending status.
  • Fail open: For read operations, degrade gracefully (e.g., show cached product data instead of real-time inventory).

Here's a fallback in practice:

java
@Service
public class PaymentService {
    private final CircuitBreaker paymentServiceBreaker;
    private final RestTemplate restTemplate;
    private final PaymentQueueService paymentQueueService;

    public PaymentResult processPayment(String orderId, BigDecimal amount) {
        try {
            return paymentServiceBreaker.executeSupplier(() -> 
                restTemplate.postForObject(
                    "https://payment-api.internal/charge",
                    new ChargeRequest(orderId, amount),
                    PaymentResult.class
                )
            );
        } catch (CallNotPermittedException e) {
            // Circuit is open; queue for retry
            paymentQueueService.enqueue(orderId, amount);
            return PaymentResult.builder()
                .status("PENDING")
                .message("Payment queued for processing")
                .orderId(orderId)
                .build();
        } catch (Exception e) {
            // Timeout, other exceptions
            log.error("Payment failed for order {}", orderId, e);
            throw new PaymentProcessingException(e);
        }
    }
}

This approach keeps the customer informed without losing the payment request. The queued payment is processed later when the service recovers, or a background job retries it.

Half-Open: Probe with a Few Requests

After the open wait duration expires, the circuit enters half-open. It's like gently testing the breaker before closing it fully. A small number of requests (permittedNumberOfCallsInHalfOpenState) are allowed through.

If these probe requests succeed, the circuit closes and normal operation resumes. If even one fails, the circuit reopens and the wait timer resets.

You don't configure half-open behavior differently; it's automatic once you set the parameters:

java
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50.0f)
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .permittedNumberOfCallsInHalfOpenState(3)  // 3 test requests allowed
    .build();

In production, monitor logs and metrics during half-open states. If probes keep failing, you may need to investigate the downstream service. If they succeed consistently, the dependency has recovered and you're back to normal flow.

Expose State in Metrics and Dashboards

A circuit breaker is only useful if you know its state. Resilience4j exposes Micrometer metrics that integrate seamlessly with Prometheus, Grafana, and other monitoring platforms.

Add the micrometer dependency:

java
// build.gradle or pom.xml
implementation 'io.github.resilience4j:resilience4j-micrometer:2.1.0'

Enable metrics collection:

java
@Configuration
public class ResilienceMetricsConfig {

    @Bean
    public MeterRegistry meterRegistry() {
        return new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
    }

    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry(MeterRegistry meterRegistry) {
        CircuitBreakerRegistry registry = CircuitBreakerRegistry.ofDefaults();
        TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(registry)
            .bindTo(meterRegistry);
        return registry;
    }
}

Key metrics exposed:

Metric Description
resilience4j_circuitbreaker_state Current state (0=closed, 1=open, 2=half-open)
resilience4j_circuitbreaker_calls_total Total calls (success, failure, slow)
resilience4j_circuitbreaker_failure_rate Current failure rate percentage
resilience4j_circuitbreaker_buffered_calls Calls in the sliding window

In Grafana, create a dashboard to visualize state transitions. Alert when a circuit opens frequently—that's a sign the dependency has a systemic issue that needs investigation.

java
// Example Prometheus query for Grafana
resilience4j_circuitbreaker_state{name="payment-service"} // 1 means OPEN, alert

Common Mistakes

Setting the failure threshold too high: A 90% failure rate means you've already lost most of your traffic. Set it lower (30–50%) to catch problems earlier.

Forgetting to handle CallNotPermittedException: If you don't catch the exception when the circuit is open, your API returns a 500 instead of gracefully degrading.

Not tuning minimumNumberOfCalls: With minimumNumberOfCalls=1, a single failure opens the circuit. Set it high enough (10–20) that transient blips don't trigger false positives.

Ignoring half-open state in monitoring: Half-open is brief but critical. If probes keep failing, you need to know. Log state transitions and alert on repeated reopens.

Applying circuit breakers everywhere: Only protect external calls to services you don't control. Local database calls or internal compute usually shouldn't have circuit breakers.

Key Takeaways

  • A circuit breaker transitions from closed (normal) to open (failing) to half-open (probing) based on failure rates and thresholds you configure.
  • In the open state, requests fail fast with a clear exception, allowing your code to invoke fallbacks and avoid wasted resources.
  • The half-open state gives the dependency a chance to recover by sending a small number of test requests before committing to closed state again.
  • Expose circuit breaker metrics via Micrometer and Prometheus so you can see state transitions in Grafana and alert on repeated failures.
  • Always handle CallNotPermittedException in your service layer and implement a fallback strategy that makes sense for your domain (queue, cache, default value, or degrade).
  • Tune minimumNumberOfCalls and slidingWindowSize to match your traffic patterns; aggressive thresholds prevent cascading failures but can also be too sensitive.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles