Articles
Resilience4j Interview Questions: Circuit Breakers, Bulkheads, and Retry Composition
Practice Resilience4j interviews with a failing dependency: circuit-breaker windows, bulkhead permits, retry amplification, decorator order, and tests.
An order API calls a payment-status service. During a partial outage, the dependency becomes slow, callers pile up, and a configured retry turns one incoming request into three downstream attempts. The team points to a Resilience4j CircuitBreaker with a window of ten calls and says, “Only ten calls can run at once.” That diagnosis is wrong—and a plausible interview follow-up asks you to show exactly why.
This is an original interview exercise, not a report that a particular employer uses Resilience4j. The official CircuitBreaker documentation, Bulkhead documentation, and Retry documentation support the library facts below. The fictional order service, thresholds, observations, and recommended policy are explicitly scenario assumptions or engineering inference. Practice the broader design judgment with Design an extensible request Retryer.
The interview fixture: one request, several attempts
Assume one incoming GET /orders/{id}/status is safe to retry and has a 600 ms caller deadline. The downstream sometimes responds in 80 ms, sometimes times out after 180 ms. The service uses a semaphore Bulkhead with four concurrent permits, a CircuitBreaker with a ten-call count-based window and minimumNumberOfCalls=10, and a Retry with maxAttempts=3. For this exercise, transport timeouts count as breaker failures; business “order not found” responses do not. The threshold and latency values are chosen for the exercise, not Resilience4j defaults or advice for every production service.
Ask first whether these components wrap a synchronous supplier, a CompletionStage, or a Spring-annotated method. Which decorator is outermost? Does the HTTP client itself retry? What timeout applies to each attempt, and what happens when the caller cancels? Are BulkheadFullException and CallNotPermittedException retryable? Without those answers, a claim about “three attempts” or “one failure recorded” is incomplete. Resilience4j can decorate functions and stack modules; the getting-started guide makes composition a deliberate choice.
Five urgent questions shape the answer. Which failure does a breaker remember, and when can it open? Why is its window not a concurrency cap? Which unit does a bulkhead protect? Does retry occur outside or inside the breaker? How will you stop retries from crossing a deadline or multiplying load? Finally, what observations distinguish a rejected call, a downstream timeout, and a fallback response?
Question 1: What does the CircuitBreaker actually decide?
The CircuitBreaker reference describes CLOSED, OPEN, and HALF_OPEN normal states. In CLOSED, calls run and their outcomes enter a count-based or time-based sliding window. Once enough calls are recorded, configured failure-rate or slow-call-rate thresholds can open the breaker. In OPEN, new calls are rejected with CallNotPermittedException; after the configured wait, a limited set of HALF_OPEN probe calls tests recovery. Special states such as METRICS_ONLY also exist, so an answer that assumes all breakers always open at a threshold misses configuration.
In our fixture, nine failures are not enough to calculate the rate because minimumNumberOfCalls=10. If ten recorded calls then meet the configured threshold, the breaker can open. The window is a sample of outcomes, not ten execution slots. The official docs give an explicit counterexample: even when a closed breaker's sliding window is smaller than the number of concurrent threads, all those threads may be permitted. The breaker protects against continued calls to a dependency judged unhealthy; it does not by itself bound active work.
The answer should identify the decision boundary: what counts as a failure? By default, exceptions are failures, but Resilience4j supports recorded, ignored, and predicate-based exceptions and result predicates. A business 404 returned as a normal value is not automatically a failed call; a client may instead translate it into an exception. A timeout from a client with its own retry can generate more than one network attempt behind a single decorated invocation. Sketch the actual adapter before interpreting breaker metrics. If all business misses count as failures, the breaker may open while the dependency is technically healthy.
Slow-call rate is distinct from failure rate. A dependency can return successfully after a long delay and still cause caller saturation. The docs support a slow-call threshold and duration; our exercise should decide whether an 180 ms timeout and an 80 ms success cross those settings. Do not quote a single “standard” threshold. Choose it from the operation's latency budget, traffic, and cost of rejecting healthy calls.
Question 2: Why is a Bulkhead needed when a breaker exists?
The Bulkhead reference offers a semaphore-style Bulkhead with maxConcurrentCalls and maxWaitDuration, plus a separate ThreadPoolBulkhead with thread-pool and queue capacity settings. Our semaphore fixture permits four simultaneous executions through that bulkhead. A fifth arrival may wait for a permit only as configured, or be rejected. The breaker still records recent outcomes separately. Four permits and a ten-call breaker window measure different things.
Suppose 20 callers arrive while the dependency takes 180 ms. While the breaker is still CLOSED and has too few completed outcomes to open, the bulkhead can keep only four protected executions in flight. The rest follow the chosen wait/rejection behavior. If the wait is long, you can move congestion into application threads instead of protecting the caller; a small queue or a fail-fast rejection may be appropriate for a tight deadline. That is an inference for the stated workload, not a default configuration recommendation.
Choose the boundary carefully. One global bulkhead for all dependencies can cause a failing payment-status service to consume permits needed by inventory calls. A per-dependency bulkhead can isolate that blast radius, but its limits still need to fit connection pools, upstream concurrency, and traffic shape. A thread-pool bulkhead adds queue and scheduling behavior; it does not make waiting free. The interviewer's useful follow-up is “What does the caller see when no permit is available?” Answer with the actual exception/fallback contract and a latency budget, not only with the term “bulkhead.”
The breaker may eventually open and reduce offered work, but it needs observations first and later admits probes. During that interval the bulkhead protects bounded concurrency. A timeout limits how long an individual attempt waits. These controls complement one another because they apply at different times and to different resources.
Question 3: Where does Retry sit in the decorator chain?
The Retry reference defines maxAttempts including the initial call. In this fixture, maxAttempts=3 means at most three invocations of whatever Retry wraps, not three extra retries. It can select results and exceptions for retry and wait between attempts. The outer-to-inner order determines what each component sees. Use parentheses to make the chain unambiguous:
| Explicit order in this exercise | Breaker observation | Bulkhead exposure | Risk to discuss |
|---|---|---|---|
Retry(CircuitBreaker(Bulkhead(call))) | Each retry attempt can be a separate breaker call | Each attempt acquires/releases a permit | Retry may repeat breaker or bulkhead rejections unless excluded |
CircuitBreaker(Bulkhead(Retry(call))) | One decorated logical call, with final outcome and duration | Permit may remain occupied across waits between attempts | Breaker hides individual attempt failures and a slow retry chain holds capacity |
Bulkhead(Retry(CircuitBreaker(call))) | Breaker still sees each attempt | One permit surrounds the whole retry cycle | Backoff inside the permit can waste scarce capacity |
This table is a causal trace of the stated nesting, not a universal “best order.” Decorator APIs evaluate outer-to-inner, and Retry re-invokes its inner supplier for each permitted attempt. The exact timing of asynchronous decorators and integration wrappers deserves a versioned test. The Spring integration documentation documents an aspect order of Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(Function))))) for its described annotation integration and exposes order settings. Do not silently apply that Spring default to hand-composed functional decorators, another integration, or a customized order.
Trace a concrete sequence: attempt 1 times out at 180 ms, attempt 2 times out at 180 ms, attempt 3 succeeds at 80 ms. Ignoring backoff and overhead, the logical call costs 440 ms, within the fixture's 600 ms deadline. Add two 100 ms waits and it reaches 640 ms: the successful third response arrives too late. A Retry setting alone cannot guarantee a whole-request deadline. The wrapper should check remaining time before another attempt, and the client timeout must fit that remainder. An interviewer should ask whether cancellation stops queued or in-flight work in the actual HTTP adapter.
Question 4: Which failures should count and which should retry?
Use a classification table rather than “retry every exception.” For this fictional idempotent GET, a connection reset or timeout could be transient; a deterministic malformed request should not be retried; an order-not-found business result may be a valid response; and CallNotPermittedException means the breaker is protecting an unhealthy path. Repeatedly retrying an OPEN breaker often spends the deadline without touching the dependency. A BulkheadFullException suggests local saturation; retrying immediately may make that saturation worse. The precise treatment is a policy choice tied to the caller's contract, not a library guarantee.
The Retry configuration has exception and result predicates, ignored exception types, attempt limits, and wait functions. The CircuitBreaker configuration independently chooses recorded and ignored failures. Review both settings. If Retry catches a failure and ultimately succeeds inside a breaker, the breaker may record the final success for the logical call depending on order. If breaker wraps each attempt, it can record two failures and one success. Neither interpretation is automatically wrong, but dashboard labels must say whether they count attempts or user requests.
For non-idempotent writes, a timeout can mean the remote side committed while the caller lost the response. Retrying a payment operation without a durable idempotency key can duplicate a charge. Our fixture intentionally uses a read, but a strong candidate flags that change in semantics before carrying the same retry policy into writes. Even idempotent reads can amplify a struggling service. Backoff, jitter, a small cap, and a caller deadline are tools to bound amplification; their specific numbers require traffic evidence.
Fallback must preserve truth. Returning a cached status may be acceptable if the response says it is stale; returning “paid” on a timeout without evidence is not. Place the fallback so it sees the failures it is meant to handle, and ensure it does not erase the error signal needed by monitoring. A final HTTP 200 can coexist with a dependency outage. Observe the dependency, the decorator, and the user-facing response separately.
Question 5: How would you test the composition rather than the annotation?
Create a deterministic fake downstream that records each invocation and can return success, throw, block, or wait until released. Fix the Resilience4j version and use the same integration style as production. Drive enough calls to meet minimumNumberOfCalls; a test of only three failed calls cannot prove a ten-call breaker will open. In a concurrency test, hold four fake calls behind a latch, send a fifth, and verify the configured bulkhead wait or rejection behavior. Avoid wall-clock sleeps when a controllable clock, fake scheduler, or short bounded wait can express the condition more precisely.
| Test event | Assertion that matters | What it prevents you from claiming |
|---|---|---|
| Nine failures in a ten-call minimum window | Breaker remains CLOSED under the stated config | “One failure always opens the breaker” |
| Enough qualifying failures | OPEN rejects new call and fake downstream sees no invocation | “A rejected call is another remote timeout” |
| Four held calls plus a fifth | Only four active fake calls through the semaphore bulkhead | “Window size caps concurrency” |
| Timeout, timeout, success | Check physical attempts, recorded breaker outcomes, and final response | “One logical call always means one breaker record” |
| Deadline expires before next backoff | No new downstream attempt; caller gets a classified outcome | “maxAttempts overrides the deadline” |
| Wait expires and HALF_OPEN permits probes | Verify probe count and transition after observed outcomes | “A timer alone proves recovery” |
Inspect event and metric streams as well as return values. The Spring docs describe Actuator metrics for breakers, retries, and bulkheads, but the exact telemetry path depends on the integration. At minimum, correlate incoming request IDs with downstream attempt IDs, attempted/accepted/rejected bulkhead entries, breaker state changes, timeout counts, and final user outcomes. Keep cardinality bounded; a unique request ID belongs in traces or logs, not as a metric label. A clean dashboard average can hide a burst of rejected calls.
A concise interview answer
“The breaker opens based on a configured window of completed outcomes after its minimum call count; its window is not a concurrency limit. I would cap active calls to the downstream with a bulkhead, set per-attempt and overall deadlines, and retry only classified transient failures while the request has time left. I would draw the actual decorator order because it determines whether the breaker records each attempt or the final logical call, and whether backoff occupies a bulkhead permit. Then I would test call counts, rejections, HALF_OPEN probes, and user-visible fallbacks with a fake downstream.”
That answer is stronger than naming three annotations because it predicts observable behavior under load. If the interviewer changes the workload from an idempotent read to a payment write, pause and revisit idempotency, retry policy, and what the fallback may truthfully say. If they change from a functional decorator to Spring annotations, verify the integration's actual aspect order before reusing your earlier trace.
Sources and further reading
- Resilience4j CircuitBreaker documentation — states, sliding windows, thresholds, and the concurrency distinction.
- Resilience4j Bulkhead documentation — semaphore and thread-pool isolation options.
- Resilience4j Retry documentation — attempt counts, wait intervals, and selection predicates.
- Resilience4j introduction — functional decorator composition.
- Resilience4j Spring integration guide — annotation aspect order, customization, and metrics.