A CircuitBreaker class built from scratch with closed/open/half-open transitions and a failure-threshold counter — mapped directly onto what Resilience4j actually does internally.
Published September 23, 2026
Circuit Breaker Pattern covers using Resilience4j's production-ready implementation. This lesson builds the same mechanism from scratch, connecting the concept to the library you'd actually reach for.
enum State { CLOSED, OPEN, HALF_OPEN }
class CircuitBreaker {
private State state = State.CLOSED;
private int failureCount = 0;
private final int failureThreshold;
private Instant openedAt;
private final Duration openDuration; // how long to stay OPEN before trying HALF_OPEN
synchronized <T> T execute(Supplier<T> call, Supplier<T> fallback) {
if (state == State.OPEN) {
if (Duration.between(openedAt, Instant.now()).compareTo(openDuration) >= 0) {
state = State.HALF_OPEN; // timed transition back to trial mode
} else {
return fallback.get(); // still open — fail fast, don't even attempt the call
}
}
try {
T result = call.get();
onSuccess();
return result;
} catch (Exception e) {
onFailure();
return fallback.get();
}
}
private void onSuccess() {
failureCount = 0;
state = State.CLOSED; // a successful HALF_OPEN trial call closes the breaker again
}
private void onFailure() {
failureCount++;
if (state == State.HALF_OPEN || failureCount >= failureThreshold) {
state = State.OPEN; // trip immediately on a failed trial call, OR once the threshold is crossed from CLOSED
openedAt = Instant.now();
}
}
}
failureCount crosses failureThreshold — the breaker gives up on the dependency and starts fast-failing every call via the fallback, without even attempting the real call.openDuration elapses, the next call is allowed through as a trial, to test whether the dependency has recovered.failureCount and fully closes the breaker; failure immediately reopens it (no second chance), resetting the open timer.This hand-rolled version is a deliberately simplified core of what Resilience4j's CircuitBreaker actually implements — the real library adds a sliding window (tracking failure rate over recent calls, not a simple cumulative counter, so an old failure eventually stops counting against the current state) and configurable wait duration in open state (matching openDuration here) — but the fundamental three-state machine and the execute()-wrapping-a-call-with-a-fallback shape is identical. Recognizing this mapping explicitly — "this is what CircuitBreaker.decorateSupplier() is actually doing internally" — is a stronger interview answer than treating the library as a black box.
A breaker that counts consecutive failures is easy to explain, but it behaves poorly under real traffic: one success resets it, even at a 60% error rate, and under heavy load a handful of failures can trip it on noise. Production breakers use a failure rate over a sliding window, a minimum number of calls before judging, and a limited number of trial calls in HALF_OPEN. All of it must be thread-safe, because many request threads call through the same breaker at once.
public final class CircuitBreaker {
public enum State { CLOSED, OPEN, HALF_OPEN }
private final int windowSize; // last N calls
private final int minimumCalls; // don't judge on too few samples
private final double failureRateThreshold; // e.g. 0.5 = 50%
private final Duration openDuration;
private final int halfOpenTrials;
private final Clock clock;
private State state = State.CLOSED;
private final boolean[] outcomes; // ring buffer: true = failure
private int index, recorded, failures;
private Instant openedAt;
private int trialsInFlight, trialSuccesses;
public CircuitBreaker(int windowSize, int minimumCalls, double failureRateThreshold,
Duration openDuration, int halfOpenTrials, Clock clock) {
this.windowSize = windowSize; this.minimumCalls = minimumCalls;
this.failureRateThreshold = failureRateThreshold; this.openDuration = openDuration;
this.halfOpenTrials = halfOpenTrials; this.clock = clock;
this.outcomes = new boolean[windowSize];
}
public <T> T call(Supplier<T> action, Function<Throwable, T> fallback) {
if (!tryAcquirePermission()) return fallback.apply(new CallNotPermittedException(state));
try {
T result = action.get();
onResult(false);
return result;
} catch (RuntimeException e) {
onResult(true);
return fallback.apply(e);
}
}
private synchronized boolean tryAcquirePermission() {
if (state == State.OPEN && !clock.instant().isBefore(openedAt.plus(openDuration))) {
state = State.HALF_OPEN; trialsInFlight = 0; trialSuccesses = 0; // time's up: allow trials
}
if (state == State.CLOSED) return true;
if (state == State.HALF_OPEN && trialsInFlight < halfOpenTrials) { trialsInFlight++; return true; }
return false; // OPEN, or trials exhausted
}
private synchronized void onResult(boolean failed) {
switch (state) {
case HALF_OPEN -> {
if (failed) trip(); // any trial failure: reopen
else if (++trialSuccesses >= halfOpenTrials) reset(); // all trials passed: close
}
case CLOSED -> {
if (recorded == windowSize && outcomes[index]) failures--; // evict the oldest outcome
outcomes[index] = failed;
if (failed) failures++;
index = (index + 1) % windowSize;
recorded = Math.min(recorded + 1, windowSize);
if (recorded >= minimumCalls && (double) failures / recorded >= failureRateThreshold) trip();
}
case OPEN -> { } // a late result from before tripping
}
}
private void trip() { state = State.OPEN; openedAt = clock.instant(); }
private void reset() { state = State.CLOSED; index = recorded = failures = 0; java.util.Arrays.fill(outcomes, false); }
}
What each piece prevents:
minimumCalls: two failures out of the first three calls after startup shouldn't open the circuit.Clock makes the OPEN → HALF_OPEN timing testable without sleeping.Not every exception means the dependency is unhealthy:
slowCallDurationThreshold, slowCallRateThreshold).A breaker is one part of a resilience stack, and the ordering matters. A typical arrangement from outermost to innermost is retry → circuit breaker → bulkhead → timeout → call:
Q: Why does a single failed HALF_OPEN trial call reopen the breaker immediately, rather than requiring several trial failures? A: HALF_OPEN exists specifically to probe cautiously — allowing only one (or a small, configurable number of) trial call(s) through avoids sending a burst of traffic back at a possibly-still-struggling dependency; immediately reopening on any trial failure is the conservative, safe default, trading a slightly slower recovery detection for not risking overwhelming a barely-recovering service.
Q: What's the actual difference between a simple failure COUNT threshold and Resilience4j's sliding window RATE? A: A raw count (as implemented above) never 'forgets' old failures within CLOSED state until a success resets it entirely — a sliding window instead considers only the last N calls (or calls within a time window), so a service that failed 5 times an hour ago but has succeeded consistently since doesn't still count those old failures toward tripping the breaker now, which is a meaningfully more accurate signal of CURRENT health.
Q: Should the fallback itself be allowed to fail? A: It should be designed to be extremely reliable and simple (a cached value, a static default, a graceful degradation message — see Circuit Breaker Pattern's fallback design options) precisely because it's the last line of defense; a fallback that can itself throw defeats the entire purpose of the breaker, which exists to guarantee SOME response rather than an unhandled exception.
Q: How would you unit test the OPEN → HALF_OPEN timed transition without actually waiting openDuration in a test? A: Inject a Clock (or a time-supplier abstraction) instead of calling Instant.now() directly, letting a test advance a fake clock instantly rather than sleeping — the same dependency-injection-for-testability principle applied to time-dependent logic throughout this course (e.g. Producer-Consumer Class Design's injectable Dice-equivalent reasoning).
Q: Why use a failure rate over a window instead of a consecutive-failure count? A: Consecutive counts reset on any success, so a service failing 70% of the time may never trip the breaker. A sliding-window rate, with a minimum number of calls, reflects recent health accurately, and avoids tripping on a few unlucky calls.
Q: Why limit the number of calls allowed in HALF_OPEN? A: A recovering service is fragile. Letting all queued traffic through the instant the open period ends can overload it again. A few trial calls decide whether to close or reopen, while everything else keeps using the fallback.
Q: Should 4xx errors count as failures? A: Usually not. They indicate a problem with the request, not with the dependency's health. Counting them would let invalid user input open the circuit for everyone. Count timeouts, connection failures, 5xx and throttling responses.