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:
@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:
@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:
@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:
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:
// build.gradle or pom.xml
implementation 'io.github.resilience4j:resilience4j-micrometer:2.1.0'
Enable metrics collection:
@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.
// 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
CallNotPermittedExceptionin 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.






Comments
Loading comments…