What does a circuit breaker actually do?
It stops calling a dependency that is clearly failing, so the caller fails fast instead of queueing behind a timeout. Three states — and the half-open one is the whole design, because it is how the breaker finds out the dependency recovered without letting all the traffic back in at once.
The Answer
Say this in the room. 45 seconds.
- A breaker watches calls to one dependency. When enough of them fail, it stops calling it and fails immediately instead.
- Closed — calls pass through, failures are counted. Open — calls are rejected without touching the dependency. Half-open — one trial call is allowed through.
- Half-open is the design. Without it the breaker would never know the dependency came back, and you would be choosing between staying open forever and letting all the traffic back at once.
- The point is not to protect the dependency. It is to stop your threads queueing behind a timeout, which is how one sick service takes down the services calling it.
- It is not a retry. A retry assumes the failure is transient; a breaker has concluded it is not. Put the retry inside the breaker, never the breaker inside the retry.
- A breaker with no timeout on the underlying call protects nothing — you cannot count failures that have not happened yet.
Understand It
The problem is your threads, not their service
When a dependency stops responding, the expensive part is not the errors. It is that every one of your request threads is sitting in a socket read waiting for a timeout, so your thread pool fills with work that is guaranteed to fail. Requests that had nothing to do with that dependency then queue behind them, and a failure over there becomes an outage over here.
Without a breaker, every call goes through and waits:
Dependency raw = new Dependency();
int failures = 0;
for (int i = 0; i < 20; i++) {
try { raw.fetch(); } catch (RuntimeException e) { failures++; }
}
System.out.println(" attempts : 20");
System.out.println(" reached the dependency : " + raw.calls);
System.out.println(" failures : " + failures); attempts : 20
reached the dependency : 20
failures : 20Twenty attempts, twenty timeouts, twenty threads held for the duration of each. The dependency told us it was down on the first call and we asked another nineteen times.
With a breaker in front of it, the first few failures are enough:
Clock clock = new Clock();
Dependency dep = new Dependency();
CircuitBreaker breaker = new CircuitBreaker(3, 1000, clock);
int rejected = 0, failed = 0;
for (int i = 0; i < 20; i++) {
try { breaker.call(dep::fetch); }
catch (OpenCircuitException e) { rejected++; }
catch (RuntimeException e) { failed++; }
}
System.out.println(" attempts : 20");
System.out.println(" reached the dependency : " + dep.calls);
System.out.println(" rejected without calling : " + rejected);
System.out.println(" state : " + breaker.state()); attempts : 20
reached the dependency : 3
rejected without calling : 17
state : OPENThree calls instead of twenty. The other seventeen failed immediately — no socket, no timeout, no thread held. That is the entire benefit: your caller degrades fast instead of degrading slowly, and fast failure is what keeps the rest of your service alive.
Note what did not happen: the seventeen rejected calls still failed. A breaker does not make anything work. It changes how long failing takes, and that is worth an outage.
Half-open is the whole design
An open breaker has to reopen eventually, and the naive options are both bad: stay open until a human intervenes, or close on a timer and send the full traffic back at a service that may still be dead.
Half-open is the third option — after a cool-down, let one call through and decide based on what happens:
Clock clock = new Clock();
Dependency dep = new Dependency();
CircuitBreaker breaker = new CircuitBreaker(3, 1000, clock);
for (int i = 0; i < 5; i++) {
try { breaker.call(dep::fetch); } catch (RuntimeException e) { }
}
System.out.println(" after the failures : " + breaker.state());
clock.advance(1000);
System.out.println(" after the cool-down : " + breaker.state());
int before = dep.calls;
try { breaker.call(dep::fetch); } catch (RuntimeException e) { }
System.out.println(" trial call reached it? : " + (dep.calls > before));
System.out.println(" still failing, so state : " + breaker.state());
clock.advance(1000);
dep.down = false;
System.out.println(" dependency recovered : " + breaker.call(dep::fetch));
System.out.println(" state : " + breaker.state()); after the failures : OPEN
after the cool-down : HALF_OPEN
trial call reached it? : true
still failing, so state : OPEN
dependency recovered : ok
state : CLOSEDThe transitions are exact because the clock is injected rather than slept on — which is also how you should test one.
Read the middle: the trial call reached the dependency and failed, and the breaker went straight back to open without a second attempt. One probe per cool-down is the rule. A service that is struggling to recover does not need your traffic helping it decide.
Then the dependency recovers, the next probe succeeds, and the breaker closes. Nobody was paged and no configuration changed.
Choosing the numbers
This is the follow-up question, and the useful answer is about consequences rather than defaults:
| Setting | Too low | Too high |
|---|---|---|
| Failure threshold | Opens on a blip; a single slow moment cuts off a healthy service | Never opens; you keep the outage you were trying to contain |
| Cool-down | Probes a sick service constantly, which is the load you removed | Stays down long after recovery |
| Timeout on the call | Counts healthy-but-slow calls as failures | Threads still queue — the breaker never sees a failure to count |
Two things matter more than the numbers.
Count a rate, not a run. Counting consecutive failures — as the model above does, for brevity — means one lucky success resets everything, so a dependency failing half the time never opens the breaker. Real implementations use a rolling window and a failure percentage, and this is the single most common misconfiguration.
One breaker per dependency. A breaker is a statement about one downstream service. Share one across two and a failure in the unimportant one blocks the important one.
What it is not
It is not a retry, and the two interact badly if nested the wrong way. A retry assumes the failure is transient. A breaker has concluded it is not. Put the retry inside the breaker so that its attempts count as one logical call — the other way round, the retry keeps hammering a breaker that is trying to stay open, and each retry burns one of your open-circuit rejections.
It is not a fallback. The breaker decides whether to call; a fallback decides what to return when you do not. They are usually used together, and the fallback is a per-call product decision: a stale price is fine, a stale balance is not.
It is not a bulkhead. A bulkhead limits how much of your capacity one dependency can consume — a separate pool per downstream — so a slow service cannot take every thread even before the breaker opens. Breakers stop calls after failures start; bulkheads bound the damage while they are happening.
And it protects the caller, not the callee. Reduced load may help the dependency recover, which is a side effect. The reason to have one is that your service stays responsive.
Reference
The correct implementation, the configuration that goes with it, and the variants worth knowing. Copy from here.
A complete breaker
Production-shaped: a rolling window, a failure rate, injected time so it is testable, and a lock so it is usable from many threads.
public final class CircuitBreaker {
public enum State { CLOSED, OPEN, HALF_OPEN }
private final int windowSize; // e.g. 20
private final int failurePercent; // e.g. 50
private final long openMillis; // e.g. 10_000
private final LongSupplier clock; // System::currentTimeMillis in production
private final boolean[] window;
private int index, recorded, failures;
private State state = State.CLOSED;
private long openedAt;
public CircuitBreaker(int windowSize, int failurePercent, long openMillis, LongSupplier clock) {
this.windowSize = windowSize;
this.failurePercent = failurePercent;
this.openMillis = openMillis;
this.clock = clock;
this.window = new boolean[windowSize];
}
public synchronized State state() {
if (state == State.OPEN && clock.getAsLong() - openedAt >= openMillis) return State.HALF_OPEN;
return state;
}
public <T> T call(Supplier<T> action) {
State entered = state(); // one read, outside the call
if (entered == State.OPEN) throw new OpenCircuitException();
try {
T result = action.get(); // NOT holding the lock
onSuccess(entered);
return result;
} catch (RuntimeException e) {
onFailure(entered);
throw e;
}
}
private synchronized void onSuccess(State entered) {
record(false);
if (entered == State.HALF_OPEN) reset(); // only clear on the transition
state = State.CLOSED;
}
private synchronized void onFailure(State entered) {
record(true);
if (entered == State.HALF_OPEN || rateExceeded()) {
state = State.OPEN;
openedAt = clock.getAsLong();
reset();
}
}
private void record(boolean failed) {
if (window[index]) failures--; // evict the outgoing outcome
window[index] = failed;
if (failed) failures++;
index = (index + 1) % windowSize;
if (recorded < windowSize) recorded++;
}
private void reset() { Arrays.fill(window, false); index = recorded = failures = 0; }
/** Never judged on a partial window — one bad call is not a 100% failure rate. */
private boolean rateExceeded() {
return recorded == windowSize && failures * 100 >= windowSize * failurePercent;
}
}
Three things in there are the difference between this and a sketch: the dependency is called outside the lock, the window is cleared only on the half-open transition, and the rate is never judged on a partial window.
With Resilience4j, which is what you will actually use
resilience4j:
circuitbreaker:
instances:
pricing:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 20 # do not judge a partial window
failureRateThreshold: 50 # percent, not a count
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 1
slowCallDurationThreshold: 2s # slow counts as failed
slowCallRateThreshold: 50
recordExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.example.NotFoundException # a 404 is an answer, not a failure
timelimiter:
instances:
pricing:
timeoutDuration: 2s # without this the breaker sees nothing
@CircuitBreaker(name = "pricing", fallbackMethod = "cachedPrice")
@Retry(name = "pricing") // retry INSIDE the breaker
public Price fetch(String sku) {
return client.get(sku);
}
/** Same signature plus the Throwable. Runs for both failures and rejections. */
private Price cachedPrice(String sku, Throwable cause) {
return cache.getIfPresent(sku); // may be null — decide what that means
}
Spring applies @Retry inside @CircuitBreaker by default, which is the ordering you want. Do not reverse it with @Order.
The timeout, which is the part that is actually load-bearing
@Bean
RestClient pricingClient(RestClient.Builder builder) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(1));
factory.setReadTimeout(Duration.ofSeconds(2)); // no default — none at all
return builder.requestFactory(factory).baseUrl(pricingUrl).build();
}
Variants
| You want | Use |
|---|---|
| Open on failure count, dead dependency | slidingWindowType: COUNT_BASED, high threshold |
| Open on failure rate over time | TIME_BASED with slidingWindowSize in seconds |
| Treat slow as failed | slowCallDurationThreshold + slowCallRateThreshold |
| Never trip on client errors | ignoreExceptions for 4xx-mapped types |
| Limit concurrent calls too | a bulkhead alongside, not instead |
Scenarios
Real situations, with the decision and the argument. Some of these have no clean answer, which is the point.
1. Your payment gateway starts failing 30% of calls. Do you put a breaker on it?
Probably not, and this is the scenario that catches people who have just learned the pattern.
A breaker converts a slow failure into a fast one, which is right when a fast failure is acceptable. For a payment you would rather wait and succeed than fail immediately, and rejecting 100% of payments because 30% are failing turns a partial outage into a total one for revenue.
What you want here is a timeout plus a bounded retry with jitter, and a bulkhead so payment calls cannot consume every thread. If you do add a breaker, the threshold belongs far higher than a read path's — and the fallback has to be "queue it for later", not "tell the user no".
The general rule: breaker where a fallback exists, retry where the operation is idempotent and worth waiting for.
2. One tenant's webhook endpoint is down. Every tenant's notifications stop.
One breaker keyed by dependency, and "the webhook service" was treated as one dependency when it is really one per tenant URL.
Key the breaker by what actually fails — here, per destination host. Resilience4j does this with a registry: registry.circuitBreaker("webhook-" + host). Watch the cardinality; per-tenant is fine at hundreds and a memory leak at millions, so it needs eviction.
This is the same reasoning as one breaker per dependency, applied one level down. The unit is not the class you call, it is the thing that can fail independently.
3. The breaker opens in staging every deploy and nobody trusts it any more.
The dependency restarts during a deploy, a handful of calls fail, and with a small window and a low threshold that is enough. The team's fix was to disable it.
The real fix is minimumNumberOfCalls. A window of 20 with a minimum of 5 will open on five failures during a restart; a minimum of 20 will not. In a low-traffic environment the window fills so slowly that any blip looks like a trend.
Worth saying plainly: a breaker that opens on deploys will be turned off, and then it is not there during the outage. Configuration that cries wolf is worse than none.
4. You put a breaker in front of your own database. Good idea?
Usually not. A breaker suits a dependency you can degrade without: a recommendations service, a pricing cache, an enrichment call. Most requests cannot do anything useful without the database, so opening the breaker converts "slow" into "down" and buys nothing.
What the database needs instead is a bounded connection pool with a short acquisition timeout — which is a bulkhead, and it gives you the fast failure without the state machine.
The exception is a read replica used for reporting where stale or absent data is acceptable. That is a real fallback, so a breaker earns its place.
5. During an incident the breaker is open and the dependency has recovered, but traffic is heavy.
One trial call per cool-down means recovery is discovered by a single request, and with a ten-second cool-down under a thousand requests a second, 9,999 of them fail while one probes.
That is the trade, and it is usually right — but if the cool-down is long and the traffic is heavy, shorten the cool-down rather than admitting more probes. Some implementations allow a small number of half-open calls, which recovers faster and risks re-loading a service that is still fragile.
There is no configuration that is right for both a service that recovers in one second and one that takes five minutes. Pick for the dependency you have, and say which you picked for.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "What does a circuit breaker do?" It watches calls to one dependency and, once enough fail, stops calling it and fails immediately. Three states: closed, open, half-open.
2. "What is the half-open state for?" Discovering that the dependency recovered without sending all the traffic back at once. After a cool-down, one trial call is allowed: success closes the breaker, failure re-opens it for another cool-down.
3. "Why does failing fast help? The request still fails." Because the cost of a failing dependency is your threads waiting on timeouts. Failing in microseconds instead of seconds keeps the pool free for requests that have nothing to do with it — that is what stops one service's outage becoming yours.
4. "How would you pick the threshold and cool-down?" From the dependency's normal error rate and recovery time. Too low and a blip cuts off a healthy service; too high and it never opens. More important than the numbers: count a failure rate over a rolling window rather than consecutive failures, or a dependency failing half the time never trips it.
5. "Retry and circuit breaker together — which wraps which?" Retry inside the breaker, so a retried call counts as one logical attempt. The other way round, the retries hammer a breaker trying to stay open and each attempt consumes a rejection.
6. "What must you have for the breaker to work at all?" A timeout on the underlying call. Without one there is no failure to count and your threads still queue — the breaker sits closed while the service dies.
7. "One breaker for all your downstreams?" No, one per dependency. A shared breaker lets an unimportant failing service block calls to a healthy critical one.
8. "What is a bulkhead and how is it different?" A limit on how much of your capacity one dependency may consume — typically a separate thread pool per downstream. It bounds damage while failures are happening; the breaker stops calls after they have.
Code traps
Trap A — predict before you run:
@CircuitBreaker(name = "pricing")
public Price fetch(String sku) {
return restTemplate.getForObject(pricingUrl + sku, Price.class);
}
Answer
If that RestTemplate has no connect and read timeouts configured — and by default it has none — the breaker is decorative. A hung dependency produces no exception, so no failure is counted, so the breaker never opens, and every thread that enters this method stays there.
The breaker is the second line of defence. The timeout is the first, and this is the most common way a resilience annotation ends up protecting nothing.
Trap B:
@Retryable(maxAttempts = 3)
@CircuitBreaker(name = "inventory")
public Stock check(String sku) { ... }
Answer
The retry is on the outside, so each user request makes three attempts through the breaker. During an outage that triples the calls the breaker has to reject, and once it opens each user request burns three rejections instead of one.
Worse, if the retry catches the open-circuit exception it will retry that too — turning a fast rejection back into three. Put the breaker outside and the retry inside, so a retried call is one logical attempt from the breaker's point of view.
Trap C:
breaker.setFailureThreshold(5); // consecutive failures
Answer
Against a dependency failing 50% of the time, this breaker never opens. Every other call succeeds and resets the counter, so five in a row essentially never happens — while half of all user requests fail.
Count a failure rate over a rolling window instead: "50% of the last 20 calls" opens correctly under partial failure, which is what real outages look like. Total failure is the easy case; the intermittent one is where the configuration matters.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "It retries the failed call." | It does the opposite — it stops calling. |
| "It protects the downstream service." | It protects the caller's threads. Reduced load is a side effect. |
| "Half-open means half the traffic goes through." | One trial call per cool-down. |
| "It makes the request succeed." | The rejected calls still fail, just immediately. |
| "Threshold means consecutive failures." | Use a rate over a window, or partial failure never trips it. |
| "The breaker handles a hung dependency." | Only if the call has a timeout. Otherwise there is nothing to count. |
Check Yourself
Q1. The rejected calls still fail. What has the breaker bought you?
Answer
Time. A rejection costs microseconds where a timeout costs seconds, so your threads stay free for requests that do not touch the failing dependency. The failure is contained instead of spreading to everything sharing the pool.
Q2. Why is one trial call the right amount in half-open?
Answer
It is enough to learn whether the dependency recovered and small enough not to be load on something still struggling. Closing fully would send all the traffic back at a service that may still be dead; staying open forever needs a human.
Q3. Your breaker is configured for five consecutive failures and never opens, while half of all requests fail. Why?
Answer
Every success resets the counter, so five in a row almost never occurs under a 50% failure rate. Count a failure percentage over a rolling window instead — partial failure is the normal shape of an outage, and consecutive counting only catches total failure.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Build the three states | 10 min |
| Challenge | Make the numbers defensible | 25 min |
| Production | The breaker that never opened | 45 min |
| Interview | Full round replay | 10 min |
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Build the three states
One concept, guided. Near-impossible to fail.
- Challenge25 min
Make the numbers defensible
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The breaker that never opened
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — circuit breakers
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- micro retries and backoff — not written yet
- micro bulkheads — not written yet
- executor service — not written yet
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.