CircuitBreaker

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A circuit breaker watches the outcome of calls to one remote dependency. While the dependency behaves, it lets every call through. When too many calls fail or are too slow, it opens and rejects calls immediately, without touching the dependency, for a while. Then it lets a few probe calls through to see whether the dependency has recovered. The rejection is cheap, so a struggling pricing-service is not buried under retries and the callers' threads are not tied up waiting for timeouts. This page covers the Resilience4j implementation completely: the states, the sliding windows, the two rates, every property, the exception rules, the events and metrics, and a worked tuning example. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026. The CircuitBreaker Java API and the property keys are the same on Spring Boot 3.5.x; only the starter differs (resilience4j-spring-boot3 with spring-boot-starter-aop instead of resilience4j-spring-boot4 with spring-boot-starter-aspectj), as described in Getting Started. Where a property exists only in Java, or its Spring Boot key differs, the property table says so.

The examples use the Bookshelf scenario: bookshelf-service calls the slow and flaky pricing-service. Imports are omitted; the types are in io.github.resilience4j.circuitbreaker unless a snippet says otherwise. Every example on this page was compiled and run against Resilience4j 2.4.0 on Spring Boot 4.1.1 with JDK 21 before it was written down, and the printed values are the values it produced.

The state machine

A CircuitBreaker is a state machine with three states that do the work and three special states you switch on deliberately.

stateDiagram-v2 [*] --> CLOSED : initialState (default) CLOSED --> OPEN : failure rate or slow-call rate >= threshold
(after minimumNumberOfCalls) OPEN --> HALF_OPEN : wait elapsed (first call, or timer if
automaticTransitionFromOpenToHalfOpenEnabled) HALF_OPEN --> CLOSED : probes below both thresholds HALF_OPEN --> OPEN : probes at or above a threshold,
or maxWaitDurationInHalfOpenState elapsed HALF_OPEN --> CLOSED : maxWaitDurationInHalfOpenState elapsed
and transitionToStateAfterWaitDuration = CLOSED CLOSED --> METRICS_ONLY : manual CLOSED --> DISABLED : manual CLOSED --> FORCED_OPEN : manual METRICS_ONLY --> CLOSED : manual DISABLED --> CLOSED : manual FORCED_OPEN --> CLOSED : manual
Figure 1. The CircuitBreaker states and transitions

Read the diagram from the top. A new breaker is CLOSED: calls flow through and each outcome is recorded in a sliding window. When the failure rate, or the slow-call rate, reaches its threshold — and the window already holds at least minimumNumberOfCalls calls — the breaker becomes OPEN. In OPEN every call is rejected with a CallNotPermittedException until waitDurationInOpenState has elapsed. The next state is HALF_OPEN, which permits exactly permittedNumberOfCallsInHalfOpenState probe calls and evaluates them against the same thresholds: if they look healthy the breaker closes with a fresh, empty window, otherwise it opens again.

Table 1. What each state does
State Calls Outcomes Events published

CLOSED

permitted

recorded in the sliding window; may open the circuit

all (SUCCESS, ERROR, IGNORED_ERROR, FAILURE_RATE_EXCEEDED, SLOW_CALL_RATE_EXCEEDED, STATE_TRANSITION, RESET)

OPEN

rejected with CallNotPermittedException (counted as not permitted)

late results of calls that started while CLOSED are still recorded

all, including NOT_PERMITTED

HALF_OPEN

only the first permittedNumberOfCallsInHalfOpenState; the others are rejected

recorded in a separate count-based window of that size; once all probes have finished the breaker closes or reopens

all

METRICS_ONLY

permitted

recorded and the rates are computed, but the breaker never opens

all; FAILURE_RATE_EXCEEDED and SLOW_CALL_RATE_EXCEEDED fire once each until the state changes

DISABLED

permitted

not recorded: the metrics stay empty

only STATE_TRANSITION and RESET

FORCED_OPEN

always rejected (counted as not permitted)

none

only STATE_TRANSITION and RESET

Two details are worth knowing before reading on. First, the OPEN to HALF_OPEN move is lazy by default: nothing happens when the wait elapses, and the first call that arrives afterwards moves the breaker to HALF_OPEN and becomes the first probe. Set automaticTransitionFromOpenToHalfOpenEnabled to move on a timer instead (see the tuning example for the trade-off). Second, a call that started while CLOSED and finishes after the breaker opened is still recorded in the window it belongs to; it does not reopen or extend the open period.

A full cycle in code

The test clock below stands in for the wall clock so that the 30-second wait can be skipped. CircuitBreakerConfig accepts a Clock since 2.3.0, and the state machine uses it for the open-state wait.

CLOSED to OPEN to HALF_OPEN to CLOSED, with a hand-driven clock
MutableClock clock = new MutableClock();   // a java.time.Clock you advance by hand in tests

CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .slidingWindowType(SlidingWindowType.COUNT_BASED)
    .slidingWindowSize(10)
    .minimumNumberOfCalls(5)
    .failureRateThreshold(50)
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .permittedNumberOfCallsInHalfOpenState(3)
    .clock(clock)
    .build();
CircuitBreaker cb = CircuitBreakerRegistry.of(config).circuitBreaker("pricing");

cb.getEventPublisher().onStateTransition(e -> System.out.println(e.getStateTransition()));

// 4 successes, 2 failures -> 33 %, still CLOSED; two more failures -> 4 of 8 = 50 % -> OPEN
call(cb, true); call(cb, true); call(cb, true); call(cb, true);
call(cb, false); call(cb, false);
call(cb, false); call(cb, false);

call(cb, true);                              // rejected: CircuitBreaker 'pricing' is OPEN ...
clock.advance(Duration.ofSeconds(31));
cb.getState();                               // still OPEN: the transition is lazy
call(cb, true);                              // the first probe moves the breaker to HALF_OPEN
call(cb, true); call(cb, true);              // 3 healthy probes -> CLOSED
The values printed by the example
after 6 calls: state=CLOSED failureRate=33.333332
after 8 calls: state=OPEN failureRate=50.0
call while open: REJECTED(CircuitBreaker 'pricing' is OPEN and does not permit further calls)
31 s later, state before any call=OPEN
after first probe: state=HALF_OPEN, 1 buffered
after 3 probes: state=CLOSED
transitions: State transition from CLOSED to OPEN, State transition from OPEN to HALF_OPEN, State transition from HALF_OPEN to CLOSED

Source: Resilience4j: CircuitBreaker (states and configuration) and the state machine at CircuitBreakerStateMachine.java at v2.4.0.

The threshold test is greater than or equal: a failure rate of exactly 50 with failureRateThreshold 50 opens the circuit, as the eighth call above shows (4 failures of 8 calls).

What HALF_OPEN decides

In HALF_OPEN the breaker keeps a separate window of permittedNumberOfCallsInHalfOpenState calls, always count-based whatever slidingWindowType says, and the minimum number of calls equals that size. Only after all probe results are in does it decide: any rate at or above a threshold reopens the circuit, otherwise it closes. Probes beyond the permitted number are rejected until the results of the permitted ones decide the state. With the default of 10 probes and a threshold of 50, up to four failed probes of ten still close the circuit, and the fifth reopens it.

Three permitted probes, one fails: 33 % is below 50 %, so the circuit closes
// breaker is OPEN and the wait has elapsed
cb.tryAcquirePermission();    // true: moves to HALF_OPEN, probe 1
cb.tryAcquirePermission();    // true: probe 2
cb.tryAcquirePermission();    // true: probe 3
cb.tryAcquirePermission();    // false: a fourth call is rejected while three are in flight

cb.onError(10, TimeUnit.MILLISECONDS, new IOException("down"));
cb.onSuccess(10, TimeUnit.MILLISECONDS);
cb.onSuccess(10, TimeUnit.MILLISECONDS);   // all three results in: 1 of 3 failed = 33 % -> CLOSED

Source: Resilience4j: CircuitBreaker and the CircuitBreaker interface at v2.4.0 (tryAcquirePermission, onSuccess, onError).

tryAcquirePermission, onSuccess and onError are the low-level protocol the decorators use; calling them by hand is rarely needed outside tests and custom integrations, and every permission you acquire must be answered with a result or a releasePermission().

If the probes do not all arrive, the breaker would wait in HALF_OPEN forever when maxWaitDurationInHalfOpenState is its default of 0. Set it to a positive duration to leave HALF_OPEN after that time, and choose where to go with transitionToStateAfterWaitDuration: OPEN (the default; the safe choice, wait again) or CLOSED (optimistic).

Leaving HALF_OPEN on a timer, to OPEN or to CLOSED
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .slidingWindowSize(2).minimumNumberOfCalls(2)
    .waitDurationInOpenState(Duration.ofSeconds(1))
    .permittedNumberOfCallsInHalfOpenState(5)
    .maxWaitDurationInHalfOpenState(Duration.ofMillis(300))
    .transitionToStateAfterWaitDuration(State.CLOSED)    // State.OPEN is the default
    .build();
// one probe of five arrives, then traffic stops: 300 ms later the breaker is CLOSED

Source: Resilience4j: CircuitBreaker (maxWaitDurationInHalfOpenState) and the v2.4.0 release notes (transitionToStateAfterWaitDuration, PR 2290).

The test run confirmed both targets: after the shortened wait the breaker was OPEN for transitionToStateAfterWaitDuration=OPEN and CLOSED for CLOSED. Only OPEN and CLOSED are accepted; the builder throws IllegalArgumentException for anything else, and for null.

Automatic versus lazy transition to HALF_OPEN

With the default automaticTransitionFromOpenToHalfOpenEnabled=false the breaker stays OPEN after the wait until a call arrives. With true it schedules the transition when it opens, so getState(), the Actuator endpoint and the metrics show HALF_OPEN without any traffic. The cost is a scheduler thread, shared by all breakers, that Resilience4j starts for it.

Automatic versus lazy transition, 200 ms wait, no calls in between
CircuitBreakerConfig automatic = CircuitBreakerConfig.custom()
    .slidingWindowSize(2).minimumNumberOfCalls(2)
    .waitDurationInOpenState(Duration.ofMillis(200))
    .enableAutomaticTransitionFromOpenToHalfOpen()     // or automaticTransitionFromOpenToHalfOpenEnabled(true)
    .build();
// two failures open it; 600 ms later, without any call: getState() == HALF_OPEN

CircuitBreakerConfig lazy = CircuitBreakerConfig.custom()
    .slidingWindowSize(2).minimumNumberOfCalls(2)
    .waitDurationInOpenState(Duration.ofMillis(200))
    .build();
// two failures open it; 600 ms later, without any call: getState() == OPEN

Source: Resilience4j: CircuitBreaker (automaticTransitionFromOpenToHalfOpenEnabled).

Special states and manual transitions

Besides the three states above, a breaker can be put in one of three states that are never entered by the thresholds. They are the operator’s tools.

  • METRICS_ONLY: the breaker behaves like a closed one that never opens. Calls are permitted, outcomes are recorded, events are published, but the circuit stays closed. It is the way to dry-run a configuration on live traffic: watch the failure rate and the FAILURE_RATE_EXCEEDED event, and switch to CLOSED when the numbers look right.

  • DISABLED: the breaker is out of the way. Calls are permitted, nothing is recorded, and only transition and reset events are published. Use it to take a breaker out of service temporarily, for example during a known-bad dependency window that you handle some other way.

  • FORCED_OPEN: the breaker rejects every call, whatever the dependency does. Use it as a manual kill switch to shed load from a dependency you know is broken or are about to restart, or to dark-launch a feature that must not call its backend yet.

Each state has a method on CircuitBreaker: transitionToClosedState(), transitionToOpenState(), transitionToHalfOpenState(), transitionToMetricsOnlyState(), transitionToDisabledState() and transitionToForcedOpenState(). There are also two variants that open the circuit for a given wait: transitionToOpenStateFor(Duration) and transitionToOpenStateUntil(Instant). And reset() returns the breaker to CLOSED from any state, with an empty window and cleared counters (including the not-permitted count), and publishes a RESET event, plus a STATE_TRANSITION event if the state changed.

Manual transitions and what the state machine does with them
CircuitBreaker cb = CircuitBreaker.ofDefaults("manual");

cb.transitionToMetricsOnlyState();       // METRICS_ONLY: records, never opens
cb.transitionToForcedOpenState();        // FORCED_OPEN: every call is rejected
cb.executeSupplier(() -> "x");           // throws CallNotPermittedException:
                                         // CircuitBreaker 'manual' is FORCED_OPEN and does not permit further calls
cb.transitionToDisabledState();          // DISABLED: calls flow, nothing is recorded
cb.transitionToClosedState();            // back to normal

cb.transitionToHalfOpenState();          // IllegalStateTransitionException: CircuitBreaker 'manual' tried an illegal
                                         // state transition from CLOSED to HALF_OPEN

Source: Resilience4j: CircuitBreaker (manual state transitions) and CircuitBreaker.java at v2.4.0 (State, StateTransition).

Not every transition is legal. The StateTransition enum in the source lists the allowed ones, and anything else throws IllegalStateTransitionException:

Table 2. Legal manual transitions (from the StateTransition enum)
From To

CLOSED

OPEN, DISABLED, METRICS_ONLY, FORCED_OPEN

OPEN

CLOSED, HALF_OPEN, DISABLED, METRICS_ONLY, FORCED_OPEN

HALF_OPEN

CLOSED, OPEN, DISABLED, METRICS_ONLY, FORCED_OPEN

FORCED_OPEN

CLOSED, OPEN, HALF_OPEN, DISABLED, METRICS_ONLY

DISABLED

CLOSED, OPEN, HALF_OPEN, FORCED_OPEN, METRICS_ONLY

METRICS_ONLY

CLOSED, DISABLED, FORCED_OPEN

So CLOSED cannot go straight to HALF_OPEN, and METRICS_ONLY cannot go to OPEN or HALF_OPEN. Moving to the same state is legal but not a no-op: the state object is replaced, so the window starts empty, and no event is published.

Starting in a state other than CLOSED

initialState (new in 2.4.0, PR 2268) creates the breaker in the given state, which makes both patterns above declarative. A dark-launched dependency starts FORCED_OPEN and an operator opens it later; a new, untested configuration starts METRICS_ONLY.

Dark launch and shadow mode from configuration
resilience4j:
  circuitbreaker:
    instances:
      recommendations-shadow:
        initialState: METRICS_ONLY      # collect rates, never open, until you are confident
      new-supplier:
        initialState: FORCED_OPEN       # reject everything until an operator closes it

The Actuator write operation

With Spring Boot, the circuitbreakers Actuator endpoint also has a write operation that performs three of the manual transitions over HTTP. Posting {"updateState":"FORCE_OPEN"}, {"updateState":"DISABLE"} or {"updateState":"CLOSE"} to /actuator/circuitbreakers/<name> calls transitionToForcedOpenState(), transitionToDisabledState() or transitionToClosedState(). The three values are the only ones the operation knows: the run returned HTTP 400 for METRICS_ONLY, and there is no HALF_OPEN or reset. The operation changes production behaviour with one request, so the endpoint must be exposed only on a management port that is not reachable from outside and secured with authentication and authorization; the securing recipe is in Observability.

Switching a breaker with the Actuator write operation (secure the endpoint first)
curl -s -X POST http://localhost:8081/actuator/circuitbreakers/pricing \
     -H 'Content-Type: application/json' \
     -d '{"updateState":"FORCE_OPEN"}'
{"circuitBreakerName":"pricing","currentState":"FORCED_OPEN","message":"pricing state has been changed successfully"}

Sliding windows

While CLOSED, the breaker aggregates outcomes in a sliding window, and the rates it compares with the thresholds are computed from that aggregate. There are two window types, chosen with slidingWindowType.

Two panels. COUNT_BASED with slidingWindowSize 10: a circular array of the last 10 call outcomes, each marked failed, slow or ok, with a running total of 10 calls, 4 failed and 1 slow giving a failure rate of 40 percent and a slow-call rate of 10 percent. TIME_BASED with slidingWindowSize 10: ten one-second buckets from t minus 9 to t, each holding its call count and failure count, with a running total of 40 calls and 14 failed giving 35 percent. A strip below states that both rates read minus one until minimumNumberOfCalls is reached and that the circuit opens when either rate reaches its threshold
Table 3. COUNT_BASED versus TIME_BASED
COUNT_BASED TIME_BASED

slidingWindowSize means

the last N calls

the last N seconds

Structure

a circular array of N call slots

a circular array of N one-second buckets, one per epoch second

Reaction under load

reacts after N calls, so the window covers a shorter time as traffic grows

covers a fixed time, so under heavy traffic it holds many calls and a sudden outage needs time to dominate the rates

Reaction when idle

old calls stay in the window however long ago they were made

idle seconds are empty buckets, and the window empties by itself

minimumNumberOfCalls

capped to slidingWindowSize (a minimum above the size is lowered)

any value, even above the number of buckets

Memory

O(N): N slots

O(N): N buckets, independent of traffic (calls are aggregated, not stored)

Reading the rates

O(1): a running total is kept

O(1): a running total is kept; the window is first moved to the current second

Both implementations are subtract-on-evict aggregations: they never recount the window. A new outcome is added to a running total, and the outcome that falls out of the window (the oldest call, or the oldest bucket) is subtracted. That is why the cost of reading a rate does not depend on the window size, and why a very large slidingWindowSize costs only memory. The minimumNumberOfCalls cap is applied in the three-argument slidingWindow(…​) method; the metrics also apply it at runtime when you set the two properties separately:

How minimumNumberOfCalls interacts with the window type
CircuitBreakerConfig.custom().slidingWindow(10, 50, SlidingWindowType.COUNT_BASED).build()
    .getMinimumNumberOfCalls();   // 10: capped to the window size
CircuitBreakerConfig.custom().slidingWindow(10, 50, SlidingWindowType.TIME_BASED).build()
    .getMinimumNumberOfCalls();   // 50: a time window of 10 s may need 50 calls

// set separately, the config keeps 50, but a COUNT_BASED breaker still opens after 10 failures
CircuitBreakerConfig c = CircuitBreakerConfig.custom().slidingWindowSize(10).minimumNumberOfCalls(50).build();

Source: Resilience4j: CircuitBreaker (sliding window) and the SlidingTimeWindowMetrics and FixedSizeSlidingWindowMetrics sources at v2.4.0.

A time-based window moves only when something touches it: it is advanced by the next recorded call or the next read. The next snippet shows the buckets expiring on a hand-driven clock:

A TIME_BASED window forgets calls older than N seconds
CircuitBreaker cb = CircuitBreakerRegistry.of(CircuitBreakerConfig.custom()
    .slidingWindowType(SlidingWindowType.TIME_BASED).slidingWindowSize(10).minimumNumberOfCalls(3)
    .failureRateThreshold(60).clock(clock).build()).circuitBreaker("tb");

call(cb, false);                             // t = 0 s
call(cb, true);                              // t = 0 s
clock.advance(Duration.ofSeconds(4));
call(cb, true);                              // t = 4 s
// buffered=3 failed=1 rate=33.333332
clock.advance(Duration.ofSeconds(7));        // t = 11 s: the t = 0 bucket has left the 10 s window
// buffered=1 failed=0 rate=-1.0   (below minimumNumberOfCalls, so the rate is -1)

Source: Resilience4j: CircuitBreaker (time-based sliding window).

The synchronization strategy

slidingWindowSynchronizationStrategy (added in 2.3.0 with the lock-free window, issue 2241) chooses how concurrent calls update the window:

  • SYNCHRONIZED (the default) guards the window with a ReentrantLock. It does not allocate extra objects per call, but threads can block each other for the short time an update takes. The lock is a ReentrantLock, not a synchronized block, so it does not pin virtual threads (a change made in 2.3.0, issue 2232).

  • LOCK_FREE updates immutable snapshots with compare-and-swap. Threads never block each other, which helps under high contention, at the price of allocating more objects.

It is a Java-only setting: it has no Spring Boot property in 2.4.0. With the starter, set it through a CircuitBreakerConfigCustomizer (the snippet in Java-only settings in a Spring Boot application shows how). TIME_BASED with LOCK_FREE requires slidingWindowSize of at least 2 (since 2.4.0, PR 2358; the builder throws IllegalArgumentException: For TIME_BASED with LOCK_FREE strategy, slidingWindowSize must be at least 2). One more detail from the source: the lock-free time-based window reads Clock.SYSTEM from resilience4j-core and ignores the clock you configure, so a test that drives time with a custom Clock should keep SYNCHRONIZED.

Failure rate and slow-call rate

The breaker computes two independent rates from the window:

  • the failure rate is failed calls / total calls as a percentage; compare failureRateThreshold;

  • the slow-call rate is slow calls / total calls; a call is slow when it takes longer than slowCallDurationThreshold (a duration exactly equal to the threshold is not slow), whether it succeeded or failed; compare slowCallRateThreshold.

Either rate reaching its threshold opens the circuit (the comparison is greater than or equal), and each can be turned off in practice: the defaults are failureRateThreshold 50 and slowCallRateThreshold 100, and since a rate of 100 needs every call to be slow, the default effectively disables slow-call detection. Lower it to make the breaker react to latency before the dependency starts failing. Both thresholds must be between 1 and 100; the builder throws IllegalArgumentException otherwise, and so does Spring Boot at startup.

Both rates read -1 until the window holds minimumNumberOfCalls calls. That is the guard against opening on three calls of which two happened to fail. getMetrics() therefore returns -1 for getFailureRate() and getSlowCallRate() when there is not enough data, which is not the same as 0.

Slow calls open the circuit even when nothing fails
CircuitBreaker cb = CircuitBreakerRegistry.of(CircuitBreakerConfig.custom()
    .slidingWindowSize(10).minimumNumberOfCalls(10)
    .failureRateThreshold(50)
    .slowCallRateThreshold(80)
    .slowCallDurationThreshold(Duration.ofSeconds(1))
    .build()).circuitBreaker("slow");

cb.getEventPublisher().onSlowCallRateExceeded(e -> log.warn("slow-call rate {}", e.getSlowCallRate()));

for (int i = 0; i < 10; i++) {
    cb.tryAcquirePermission();
    cb.onSuccess(i < 3 ? 200 : 1500, TimeUnit.MILLISECONDS);   // 3 fast calls, then 7 slow ones
}
// state=CLOSED slowRate=70.0 failureRate=0.0
cb.tryAcquirePermission();
cb.onSuccess(1500, TimeUnit.MILLISECONDS);     // the oldest, fast, call slides out: 8 of 10 are slow
// state=OPEN slowRate=80.0 slowCalls=7 ... SLOW_CALL_RATE_EXCEEDED 80.0 was published

Source: Resilience4j: CircuitBreaker (slowCallRateThreshold, slowCallDurationThreshold).

Slow calls are about latency the caller would have suffered, so slowCallDurationThreshold should sit above the dependency’s normal tail latency and below the caller’s own timeout. A TimeLimiter that cancels at two seconds hides nothing from the breaker, since the timeout is a failure. Pair them as described in TimeLimiter and Fallbacks & Composition.

Every property with its default

The table lists every property of CircuitBreakerConfig as of 2.4.0, its default (the DEFAULT_* constants and field initializers in CircuitBreakerConfig.java), the builder method, and the Spring Boot key. Spring Boot keys live under resilience4j.circuitbreaker.instances.<name>. for one instance and resilience4j.circuitbreaker.configs.<name>. for a shared configuration an instance inherits with baseConfig.

Table 4. Thresholds, window and half-open
Property Default Builder method Meaning and notes

failureRateThreshold

50 (percent)

failureRateThreshold(float)

Open at or above this failure rate. Valid range 1 to 100.

slowCallRateThreshold

100 (percent)

slowCallRateThreshold(float)

Open at or above this slow-call rate. Valid range 1 to 100. The default never fires before every call is slow.

slowCallDurationThreshold

60s (60 000 ms)

slowCallDurationThreshold(Duration)

A call longer than this counts as slow. Spring Boot reads a Duration (800ms, 2s).

slidingWindowType

COUNT_BASED

slidingWindowType(…​)

COUNT_BASED or TIME_BASED.

slidingWindowSize

100

slidingWindowSize(int)

Calls (COUNT_BASED) or seconds (TIME_BASED). At least 1.

minimumNumberOfCalls

100

minimumNumberOfCalls(int)

Calls needed before any rate is computed (otherwise -1). At least 1. Capped to the window size for COUNT_BASED.

slidingWindowSynchronizationStrategy

SYNCHRONIZED

slidingWindowSynchronizationStrategy(…​)

SYNCHRONIZED or LOCK_FREE. Java only: no Spring Boot key in 2.4.0.

permittedNumberOfCallsInHalfOpenState

10

permittedNumberOfCallsInHalfOpenState(int)

Probe calls in HALF_OPEN. At least 1.

maxWaitDurationInHalfOpenState

0 (wait indefinitely)

maxWaitDurationInHalfOpenState(Duration)

Longest stay in HALF_OPEN; 0 means wait for all probes.

transitionToStateAfterWaitDuration

OPEN

transitionToStateAfterWaitDuration(State)

OPEN or CLOSED; used when maxWaitDurationInHalfOpenState elapses. New in 2.4.0 (PR 2290).

Table 5. The open state, the initial state and diagnostics
Property Default Builder method Meaning and notes

waitDurationInOpenState

60s (60 000 ms)

waitDurationInOpenState(Duration)

Fixed wait before HALF_OPEN. At least 1 ms.

waitIntervalFunctionInOpenState

fixed 60 s

waitIntervalFunctionInOpenState(IntervalFunction)

Wait as a function of the number of consecutive openings (exponential, randomized, custom). Do not combine with waitDurationInOpenState: build() throws IllegalStateException. Spring Boot: enableExponentialBackoff, exponentialBackoffMultiplier, exponentialMaxWaitDurationInOpenState, enableRandomizedWait, randomizedWaitFactor.

automaticTransitionFromOpenToHalfOpenEnabled

false

automaticTransitionFromOpenToHalfOpenEnabled(boolean), enableAutomaticTransitionFromOpenToHalfOpen()

Move to HALF_OPEN on a timer instead of on the next call.

initialState

CLOSED

initialState(State)

State of a newly created breaker. New in 2.4.0 (PR 2268).

writableStackTraceEnabled

true

writableStackTraceEnabled(boolean)

false makes CallNotPermittedException skip the stack trace, which is cheap and keeps logs short.

clock

Clock.systemUTC()

clock(Clock)

Time source for the open-state wait and the time-based window. Java only. Added in 2.3.0.

Table 6. What counts as a failure
Property Default Builder method Meaning and notes

recordExceptions

empty (every exception)

recordExceptions(Class…​)

Exception classes (and subclasses) recorded as failures. If set, every other exception counts as a success.

ignoreExceptions

empty

ignoreExceptions(Class…​)

Exception classes neither a failure nor a success: not recorded at all.

recordFailurePredicate

throwable → true

recordException(Predicate<Throwable>)

Custom rule. Spring Boot takes a class name of a Predicate<Throwable> with a public no-argument constructor.

ignoreExceptionPredicate

throwable → false

ignoreException(Predicate<Throwable>)

Custom ignore rule. Spring Boot takes a class name, as above.

recordResultPredicate

result → false

recordResult(Predicate<Object>)

Count a returned value as a failure (for example a response with status 503). Spring Boot key since 2.1.0.

ignoreExceptionsPrecedenceEnabled

false

ignoreExceptionsPrecedenceEnabled(boolean), enableIgnoreExceptionsPrecedence()

Makes the configured record predicate itself respect the ignore rules. Java only: not a Spring Boot property in version 2.4.0. See The record-versus-ignore discrepancy.

Table 7. Spring Boot-only keys
Key Default Meaning

baseConfig

none

Name of a configs.<name> entry the instance inherits; the instance’s own keys override it.

registerHealthIndicator

false

Include the instance in the circuitBreakers health indicator (also needs management.health.circuitbreakers.enabled).

allowHealthIndicatorToFail

false

Report DOWN instead of the custom CIRCUIT_OPEN status while open. See Observability.

eventConsumerBufferSize

100

Size of the ring buffer behind /actuator/circuitbreakerevents. At least 1.

enableExponentialBackoff, exponentialBackoffMultiplier, exponentialMaxWaitDurationInOpenState

off, library default 1.5, none

Exponential open-state wait based on waitDurationInOpenState. See Waiting in the open state.

enableRandomizedWait, randomizedWaitFactor

off, library default 0.5

Randomized open-state wait based on waitDurationInOpenState. Cannot be combined with exponential backoff.

Source: Resilience4j: CircuitBreaker (the property table, which lacks several rows listed here) and, for the verified defaults, CircuitBreakerConfig.java at v2.4.0 and CommonCircuitBreakerConfigurationProperties.java at v2.4.0.

Every default in the first three tables was asserted against a running 2.4.0 configuration. The assertion block is the quickest way to keep your own copy of this table honest:

Asserting the library defaults
CircuitBreakerConfig c = CircuitBreakerConfig.ofDefaults();
assertThat(c.getFailureRateThreshold()).isEqualTo(50f);
assertThat(c.getSlowCallRateThreshold()).isEqualTo(100f);
assertThat(c.getSlowCallDurationThreshold()).isEqualTo(Duration.ofSeconds(60));
assertThat(c.getSlidingWindowType()).isEqualTo(SlidingWindowType.COUNT_BASED);
assertThat(c.getSlidingWindowSize()).isEqualTo(100);
assertThat(c.getMinimumNumberOfCalls()).isEqualTo(100);
assertThat(c.getSlidingWindowSynchronizationStrategy()).isEqualTo(SlidingWindowSynchronizationStrategy.SYNCHRONIZED);
assertThat(c.getPermittedNumberOfCallsInHalfOpenState()).isEqualTo(10);
assertThat(c.getMaxWaitDurationInHalfOpenState()).isEqualTo(Duration.ZERO);
assertThat(c.getTransitionToStateAfterWaitDuration()).isEqualTo(State.OPEN);
assertThat(c.getWaitIntervalFunctionInOpenState().apply(1)).isEqualTo(60_000L);
assertThat(c.isAutomaticTransitionFromOpenToHalfOpenEnabled()).isFalse();
assertThat(c.getInitialState()).isEqualTo(State.CLOSED);
assertThat(c.isWritableStackTraceEnabled()).isTrue();
assertThat(c.isIgnoreExceptionsPrecedenceEnabled()).isFalse();

Java-only settings in a Spring Boot application

slidingWindowSynchronizationStrategy, clock and ignoreExceptionsPrecedenceEnabled have no key in resilience4j.circuitbreaker. Spring Boot binds instances and configs entries to a fixed property class and silently ignores unknown keys: a YAML entry with ignoreExceptionsPrecedenceEnabled: true starts without any warning, and the resulting configuration is still false (the run printed SYNCHRONIZED and false for the two keys that were set in YAML). Set them with a CircuitBreakerConfigCustomizer bean, which the starter applies on top of the YAML for the instance with the matching name:

A customizer for the settings that have no YAML key
import io.github.resilience4j.common.circuitbreaker.configuration.CircuitBreakerConfigCustomizer;

@Configuration
class PricingCircuitBreakerSettings {

    @Bean
    CircuitBreakerConfigCustomizer pricingJavaOnlySettings() {
        return CircuitBreakerConfigCustomizer.of("pricing", builder -> builder
            .slidingWindowSynchronizationStrategy(SlidingWindowSynchronizationStrategy.LOCK_FREE)
            .ignoreExceptionsPrecedenceEnabled(true));
    }
}

The customizer’s values win over the YAML for the keys it sets, and the YAML values (window size, thresholds, and so on) are kept. After adding it, registry.circuitBreaker("pricing").getCircuitBreakerConfig() reported LOCK_FREE and true next to the YAML-defined window size. The full customizer story is in Spring Boot Configuration.

Two more Spring Boot rules matter for this module. Exception classes in recordExceptions and ignoreExceptions are loaded at startup, so a misspelled class name stops the application (a ClassNotFoundException reported by the binder, as the run showed for HttpServerErorException), and an out-of-range number such as failureRateThreshold: 150 fails with failureRateThreshold must be between 1 and 100. Fail-fast is good here: a typo in a failure rule is caught on deployment, not discovered during an incident. And predicate classes (recordFailurePredicate, ignoreExceptionPredicate, recordResultPredicate) are created by reflection with a public no-argument constructor; they are plain objects, not Spring beans, so they cannot inject other beans.

A predicate class and its Spring Boot configuration
public class PricingFailurePredicate implements Predicate<Throwable> {
    @Override
    public boolean test(Throwable t) {
        return t instanceof ResourceAccessException                       // I/O errors, timeouts
            || t instanceof HttpServerErrorException                      // 5xx
            || (t instanceof HttpClientErrorException e && e.getStatusCode().value() == 429);
    }
}

public class StalePriceIsAFailure implements Predicate<Object> {
    @Override
    public boolean test(Object result) {
        return result instanceof Money money && money.stale();
    }
}
resilience4j:
  circuitbreaker:
    instances:
      pricing:
        recordFailurePredicate: com.example.bookshelf.PricingFailurePredicate
      prices-by-result:
        recordResultPredicate: com.example.bookshelf.StalePriceIsAFailure

Source: Resilience4j: CircuitBreaker (recordFailurePredicate, recordResultPredicate) and the ClassUtils.instantiatePredicateClass source at v2.4.0.

The run confirmed that the 429 and 5xx exceptions are recorded, a 404 is not, and that two stale results in a window of two open the circuit.

What counts as a failure

When a call throws, the breaker classifies the exception in this order (the logic is in handleThrowable of the state machine):

  1. If the ignore predicate matches (ignoreExceptions or ignoreExceptionPredicate), the call is ignored: it is not recorded as a failure or a success, the permission is released, and an IGNORED_ERROR event is published.

  2. Otherwise, if the record predicate matches (recordExceptions or recordFailurePredicate; by default every exception), the call is recorded as a failure.

  3. Otherwise it is recorded as a success. This is the surprise: once you list recordExceptions, every exception that is not on the list counts as a success, so a NullPointerException thrown by your own code makes the breaker healthier.

A call that returns normally is a success unless recordResultPredicate matches the result, in which case it is recorded as a failure (the event carries a ResultRecordedAsFailureException). Exceptions wrapped in a CompletionException or an ExecutionException, as asynchronous code produces, are unwrapped first, so the cause is what gets classified.

Record, ignore and the unlisted exception
CircuitBreakerConfig c = CircuitBreakerConfig.custom().slidingWindowSize(2).minimumNumberOfCalls(2)
    .recordExceptions(IOException.class, TimeoutException.class)
    .ignoreExceptions(IllegalArgumentException.class)
    .build();
CircuitBreaker cb = CircuitBreakerRegistry.of(c).circuitBreaker("x");

// throws IllegalArgumentException("bad isbn"):  buffered=0                (ignored)
// throws IllegalStateException("bug"):          buffered=1, failed=0      (not on the record list: a success)
// throws TimeoutException("slow"):              failed=1                  (recorded)

Source: Resilience4j: CircuitBreaker (recordExceptions, ignoreExceptions) and the state machine at v2.4.0.

Counting a returned value, for a client that returns an object instead of throwing on a 503:

Recording a result as a failure
record Reply(int status) { }

CircuitBreaker cb = CircuitBreakerRegistry.of(CircuitBreakerConfig.custom()
    .slidingWindowSize(2).minimumNumberOfCalls(2)
    .recordResult(r -> r instanceof Reply reply && reply.status() == 503)
    .build()).circuitBreaker("x");

cb.executeSupplier(() -> new Reply(503));
cb.executeSupplier(() -> new Reply(503));
// state=OPEN failed=2

Source: Resilience4j: CircuitBreaker (recordResultPredicate).

The record-versus-ignore discrepancy

The documentation is inconsistent about which wins when an exception matches both lists, and 2.4.0 added a flag around it. Here is what the sources and a run show.

  • The readme.io page says an ignored exception is "not a failure nor a success, even if" it is also in recordExceptions. The Javadoc of ignoreExceptions says the same: ignore has priority.

  • The 2.4.0 Javadoc of ignoreExceptionsPrecedenceEnabled says that when it is false, "the legacy behavior is used where recordExceptions takes precedence for backward compatibility". Issue 2295 and pull request 2304, which added the flag, describe the problem the same way: a subclass in ignoreExceptions whose superclass is in recordExceptions "may still be recorded".

  • In the code, the state machine tests the ignore predicate first and returns, in 2.3.0 as well as in 2.4.0, so for the decision "failure, success or ignored" the ignore list already wins. The same input produced an IGNORED_ERROR event with and without the flag: for recordExceptions: [IOException] and ignoreExceptions: [FileNotFoundException], a FileNotFoundException was ignored in both modes.

  • What the flag changes is the record predicate as an object: config.getRecordExceptionPredicate().test(fnf) returns true by default and false with ignoreExceptionsPrecedenceEnabled(true), because the flag wraps the record predicate in !ignore && record. That matters to code that reads the predicate directly (a custom decorator, a health check, a test assertion), not to the breaker’s own accounting.

The same exception, with and without the flag
CircuitBreakerConfig legacy = CircuitBreakerConfig.custom()
    .recordExceptions(IOException.class).ignoreExceptions(FileNotFoundException.class).build();
CircuitBreakerConfig precedence = CircuitBreakerConfig.from(legacy).ignoreExceptionsPrecedenceEnabled(true).build();

Throwable fnf = new FileNotFoundException("no such isbn");
legacy.getRecordExceptionPredicate().test(fnf);       // true   (as an object)
legacy.getIgnoreExceptionPredicate().test(fnf);       // true
precedence.getRecordExceptionPredicate().test(fnf);   // false
// a breaker built from either config publishes IGNORED_ERROR for fnf

The practical rule: put the exceptions you want ignored in ignoreExceptions and expect them to be ignored, flag or no flag; do not rely on the flag to change what the breaker records. And do not try to enable it from YAML: it is not a Spring Boot property in 2.4.0, so a YAML entry does nothing (use the customizer above if you need the record predicate to reflect it). The Retry module has no such flag at all.

Waiting in the open state

waitDurationInOpenState is a fixed wait. A dependency that stays broken is probed every 60 seconds by default, and every probe round briefly lets traffic reach it. A growing wait makes the breaker patient with a dependency that keeps failing and quick with one that recovers: exponential backoff doubles the wait on each consecutive reopening, and randomized wait spreads probes from many instances so they do not arrive at the same moment.

Interval functions for the open-state wait
IntervalFunction exp = IntervalFunction.ofExponentialBackoff(Duration.ofSeconds(20), 2.0, Duration.ofMinutes(2));
// attempts 1..6 -> 20 s, 40 s, 80 s, 120 s, 120 s, 120 s

IntervalFunction rnd = IntervalFunction.ofRandomized(Duration.ofSeconds(20), 0.5);
// each draw is between 10 s and 30 s (1000 draws gave 10 000 to 29 970 ms)

CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .waitIntervalFunctionInOpenState(exp)    // instead of waitDurationInOpenState(...), never both
    .build();

Source: Resilience4j: CircuitBreaker (waitIntervalFunctionInOpenState) and the IntervalFunction source at v2.4.0.

The attempt passed to the function is the number of consecutive openings: it grows each time a HALF_OPEN breaker opens again and resets when the breaker closes. A breaker that opens, recovers and later opens again starts from the first interval again. A run with a hand-driven clock confirmed it: after the first opening the wait was 10 seconds, after the probe failed it was 20.

Configuring both waitDurationInOpenState and waitIntervalFunctionInOpenState is a bug, and build() says so: The waitIntervalFunction was configured multiple times which could result in an undesired state. In Spring Boot, the same function is chosen with keys next to waitDurationInOpenState:

Spring Boot keys for exponential or randomized open-state waits
resilience4j:
  circuitbreaker:
    instances:
      pricing:
        waitDurationInOpenState: 20s
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        exponentialMaxWaitDurationInOpenState: 2m
      isbn-metadata:
        waitDurationInOpenState: 10s
        enableRandomizedWait: true
        randomizedWaitFactor: 0.25

Enabling both exponential backoff and randomized wait throws IllegalStateException at startup. The wait keys are applied together, from the properties object that sets waitDurationInOpenState: an instance that inherits waitDurationInOpenState, enableExponentialBackoff and the multiplier all from one baseConfig gets the exponential waits (10, 30 and 90 seconds for a multiplier of 3), but an instance that sets only enableExponentialBackoff and inherits the wait from its baseConfig silently keeps the fixed wait, and enableExponentialBackoff without any waitDurationInOpenState leaves the 60-second default; the run showed both. With no multiplier the library default of 1.5 applies (10, 15 and 22.5 seconds). A randomized wait with a factor of 0.25 around 10 seconds produced values between 7 500 and 12 500 ms.

Thread safety

A CircuitBreaker is thread-safe and meant to be shared: one instance per remote dependency, used by every thread. State changes are atomic, the metrics are updated under the window’s lock (or with compare-and-swap), and the open-to-half-open move is guarded so that exactly one thread performs it.

What the sliding window does not do is limit concurrency. The breaker counts and rates the calls; it does not queue them, cap the number in flight, or serialize them. In CLOSED, any number of threads can be inside the protected call at once; the run had 50 virtual threads inside a default breaker simultaneously. To limit concurrent calls, use a Bulkhead (see Bulkhead). The one place the breaker does cap concurrency is HALF_OPEN, where only permittedNumberOfCallsInHalfOpenState probes are granted, even under contention: 40 threads asked at the same moment and exactly 3 were permitted.

The window does not limit concurrency; HALF_OPEN does
CircuitBreaker cb = CircuitBreaker.ofDefaults("concurrent");
CountDownLatch allInside = new CountDownLatch(50);
AtomicInteger inside = new AtomicInteger();

try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 50; i++) {
        pool.submit(() -> cb.executeCallable(() -> {
            inside.incrementAndGet();
            allInside.countDown();
            allInside.await(5, TimeUnit.SECONDS);     // every thread waits until all 50 are inside
            return "ok";
        }));
    }
}
// inside.get() == 50, state CLOSED: the breaker did not limit anything

// HALF_OPEN with permittedNumberOfCallsInHalfOpenState(3): 40 threads call tryAcquirePermission()
// -> exactly 3 return true

Two consequences. First, a breaker’s counts can overshoot slightly under contention: calls that started while CLOSED and finish after the threshold was crossed are still recorded, so the failure rate you read can be a little different from the one that triggered the transition. Second, a breaker alone does not protect a service from a slow dependency that does not fail: only the slow-call rate and a timeout do, and a Bulkhead caps how many threads a slow dependency can hold.

CallNotPermittedException, events and getMetrics()

When the breaker rejects a call it throws CallNotPermittedException, an unchecked exception (a RuntimeException) that says which breaker rejected it. It is thrown in OPEN, FORCED_OPEN and for the excess probes in HALF_OPEN. The message names the state and the breaker, getCausingCircuitBreakerName() returns the name, and there is no cause. It is created without a stack trace when writableStackTraceEnabled is false, which is worth doing: it is thrown in bulk during an outage and a stack trace per rejection is pure overhead.

Catching the rejection, and the shape of the exception
CircuitBreaker cb = CircuitBreaker.of("pricing", CircuitBreakerConfig.custom()
    .slidingWindowSize(2).minimumNumberOfCalls(2).writableStackTraceEnabled(false).build());
// ... two failures open it

try {
    cb.executeSupplier(() -> "x");
} catch (CallNotPermittedException e) {
    e.getMessage();                         // CircuitBreaker 'pricing' is OPEN and does not permit further calls
    e.getCausingCircuitBreakerName();       // pricing
    e.getStackTrace().length;               // 0, because writableStackTraceEnabled is false
}

CallNotPermittedException is not a failure of the dependency: it is the breaker doing its job, and it is not recorded in the window. Handle it where you can answer without the dependency, which is the fallback (see Fallbacks & Composition), and do not retry it: a Retry around the breaker should list it in ignoreExceptions, and the recommended aspect order puts Retry outside the breaker, as explained in Annotations & Aspect Order.

Events

cb.getEventPublisher() registers consumers for the breaker’s events. The typed registration methods are onSuccess, onError, onIgnoredError, onCallNotPermitted, onStateTransition, onFailureRateExceeded, onSlowCallRateExceeded and onReset; onEvent receives all of them.

Table 8. CircuitBreaker events
Event type Published when Payload

SUCCESS

a call succeeded (or a non-recorded exception counted as a success)

elapsed duration

ERROR

a call failed and was recorded

elapsed duration, the Throwable

IGNORED_ERROR

an ignored exception

elapsed duration, the Throwable

NOT_PERMITTED

a call was rejected

breaker name

STATE_TRANSITION

the state changed (always published, even in DISABLED and FORCED_OPEN)

getStateTransition() with from and to states

RESET

reset() was called (always published)

breaker name

FAILURE_RATE_EXCEEDED

the failure rate reached its threshold

the failure rate

SLOW_CALL_RATE_EXCEEDED

the slow-call rate reached its threshold

the slow-call rate

The enum CircuitBreakerEvent.Type also lists FORCED_OPEN and DISABLED, but the 2.4.0 state machine never publishes them: a forced-open or disabled breaker publishes only STATE_TRANSITION and RESET. Every event carries the breaker name and a creation time. Event consumers run on the calling thread, inline with the call that caused the event, so they must be fast and must not throw (an exception inside a consumer is logged at warn level and swallowed).

Logging the interesting events of one breaker
cb.getEventPublisher()
    .onStateTransition(e -> log.warn("{}: {} -> {}", e.getCircuitBreakerName(),
        e.getStateTransition().getFromState(), e.getStateTransition().getToState()))
    .onFailureRateExceeded(e -> log.warn("{}: failure rate {}", e.getCircuitBreakerName(), e.getFailureRate()))
    .onCallNotPermitted(e -> log.debug("{}: call rejected", e.getCircuitBreakerName()));

Source: Resilience4j: CircuitBreaker (consume emitted events) and the event package at v2.4.0.

The sequence for two failures in a window of two, a rejection and a reset, as recorded by a consumer registered with every typed method, was ERROR, ERROR, FAILURE_RATE_EXCEEDED, STATE_TRANSITION (CLOSED to OPEN), NOT_PERMITTED, STATE_TRANSITION (OPEN to CLOSED), RESET. Note the order: the rate-exceeded event comes before the transition, so it can be used as an early warning. In Spring Boot the same events, in a ring buffer of eventConsumerBufferSize, are served by the circuitbreakerevents endpoint, and the metrics become Micrometer gauges; both are covered in Observability.

getMetrics()

cb.getMetrics() returns a live view of the current state’s window:

Table 9. CircuitBreaker.Metrics
Method Returns

getFailureRate()

failure percentage; -1 below minimumNumberOfCalls

getSlowCallRate()

slow-call percentage; -1 below minimumNumberOfCalls

getNumberOfBufferedCalls()

calls in the window

getNumberOfSuccessfulCalls(), getNumberOfFailedCalls()

successes and failures in the window

getNumberOfSlowCalls(), getNumberOfSlowSuccessfulCalls(), getNumberOfSlowFailedCalls()

slow calls in the window, and the split

getNumberOfNotPermittedCalls()

calls rejected while the current counters were live (they restart when the breaker closes again or is reset)

Because each state owns its window, the numbers reset at transitions: HALF_OPEN starts with an empty probe window, and a breaker that closes again starts with an empty window. Entering CLOSED (including reset()), HALF_OPEN, DISABLED or METRICS_ONLY creates fresh counters; only OPEN keeps the window that tripped it, which is why the Actuator output of an open breaker still shows the failed calls. After the reset in the events example above, buffered, failed and not-permitted calls were all zero and getFailureRate() was -1. These are the numbers behind the bufferedCalls, failedCalls, slowCalls and notPermittedCalls fields in the /actuator/circuitbreakers output.

A worked example: tuning the pricing-service breaker

pricing-service is slow and flaky. Under normal load, bookshelf-service makes about 20 calls per second per instance, the 99th percentile latency is 400 ms and a call that exceeds the client timeout takes about 2 seconds to fail. The goal is to stop calling it within seconds of an outage or a slowdown, to answer from the last known price meanwhile, and to come back gradually without a thundering herd. The reasoning goes parameter by parameter.

  • Window. A TIME_BASED window sized in seconds keeps the reaction time independent of traffic. How long the window is matters more than anything else: with a total outage at 20 calls per second, a 30-second window needed 15 seconds to reach a 50 % failure rate (the old healthy calls dominate it), a 10-second window needed 5 seconds, a 5-second window 3. Ten seconds is the compromise between reacting fast and not flapping on a short blip.

  • Minimum number of calls. 20 is about one second of traffic at this load, so the window is trusted almost immediately, but a quiet instance with a handful of calls per window cannot open on two unlucky failures.

  • Failure rate. 50 is the default and a reasonable bar: half of recent calls failing is not noise.

  • Slow calls. slowCallDurationThreshold is 800 ms, twice the p99 and well below the 2-second client timeout, and slowCallRateThreshold is 70. The same simulation with every call taking 1.2 seconds opened the circuit after 7 seconds with a 10-second window and 21 seconds with a 30-second window, with a failure rate of 0: only the slow-call rate noticed.

  • Open wait. 20 seconds on the first opening, then 40, 80 and so on up to 2 minutes, through exponential backoff.

  • Half-open. Five probes, and give up on them after 10 seconds, back to OPEN.

  • Failures. I/O errors and 5xx count; 4xx do not (a client error says nothing about the dependency’s health).

application.yml: the tuned pricing breaker
resilience4j:
  circuitbreaker:
    instances:
      pricing:
        registerHealthIndicator: true
        # evaluate the last 10 seconds of traffic, but never before 20 calls were seen
        slidingWindowType: TIME_BASED
        slidingWindowSize: 10
        minimumNumberOfCalls: 20
        # open when half the calls fail, or when 70 % of them take longer than 800 ms
        failureRateThreshold: 50
        slowCallDurationThreshold: 800ms
        slowCallRateThreshold: 70
        # stay open 20 s, then 40 s, 80 s ... up to 2 minutes, on consecutive re-openings
        waitDurationInOpenState: 20s
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        exponentialMaxWaitDurationInOpenState: 2m
        automaticTransitionFromOpenToHalfOpenEnabled: true
        # probe with 5 calls; if they do not all arrive within 10 s, go back to OPEN
        permittedNumberOfCallsInHalfOpenState: 5
        maxWaitDurationInHalfOpenState: 10s
        transitionToStateAfterWaitDuration: OPEN
        # what counts as a failure
        recordExceptions:
          - org.springframework.web.client.ResourceAccessException
          - org.springframework.web.client.HttpServerErrorException
          - java.util.concurrent.TimeoutException
        ignoreExceptions:
          - org.springframework.web.client.HttpClientErrorException

management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers
  endpoint:
    health:
      show-details: always
  health:
    circuitbreakers:
      enabled: true

The YAML above was bound in a Spring Boot 4.1.1 test as written, and the resulting configuration was asserted: TIME_BASED, window 10, minimum 20, slow-call threshold 800 ms and 70 %, open waits of 20, 40, 80, 120 and 120 seconds for attempts 1 to 5, automatic transition on, five probes, a 10-second half-open limit, ResourceAccessException recorded and a 404 HttpClientErrorException ignored. The Java side is the annotated client from Getting Started, unchanged: the tuning lives only in the YAML.

The protected call, unchanged
@CircuitBreaker(name = "pricing", fallbackMethod = "lastKnownPrice")
public Money price(String isbn) {
    return http.get().uri("/prices/{isbn}", isbn).retrieve().body(Money.class);
}

Money lastKnownPrice(String isbn, Throwable t) {
    return new Money(isbn, 0, true);       // stale: the caller can tell a degraded price from a live one
}

Source: Resilience4j: Spring Boot 2 & 3 (@CircuitBreaker and fallbackMethod).

Replaying an outage

The next chart replays the configuration against 20 calls per second on a hand-driven clock. For two minutes of history the pricing-service is healthy for 10 seconds, goes down, and comes back after 50. The times are seconds from the start, as printed by the run.

timeline title pricing breaker: outage at +10 s, recovery at +50 s section Healthy +0 s : CLOSED : 20 calls per second, all fast section Outage +10 s : pricing-service starts timing out +15 s : failure rate reaches 50 % over 200 calls : CLOSED to OPEN : all calls rejected, fallback answers section Probing +35 s : 20 s wait over : OPEN to HALF_OPEN : 5 probes fail : back to OPEN with a 40 s wait +50 s : pricing-service recovers section Recovery +75 s : 40 s wait over : OPEN to HALF_OPEN : 5 probes succeed : HALF_OPEN to CLOSED
Figure 2. One outage, replayed on a hand-driven clock

The exact transitions the run printed were: CLOSED → OPEN at +14.95 s, OPEN → HALF_OPEN at +35 s followed by HALF_OPEN → OPEN 0.2 s later (the second wait was therefore 40 seconds), OPEN → HALF_OPEN at +75 s and HALF_OPEN → CLOSED another 0.2 s later. While open, all 20 calls of every second were rejected (notPermitted reached 115 by +40 s).

Table 10. Seconds until the circuit opens after the dependency degrades (20 calls/s, 40 s of healthy history, TIME_BASED)
Window Total outage (timeouts) Every call takes 1.2 s Notes

30 s

15 s

21 s

too slow to react at this load

10 s

5 s

7 s

the choice above

5 s

3 s

4 s

reacts faster, flaps more on brief blips

Seeing it work end to end

The same YAML, with the durations shortened (minimumNumberOfCalls 4, waitDurationInOpenState 1 s, exponentialMaxWaitDurationInOpenState 4 s, two probes, slowCallDurationThreshold 300 ms) so the test finishes in seconds, was exercised through the real controller, the @CircuitBreaker client and a WireMock stub of pricing-service. Ten 404 responses left the breaker CLOSED with zero buffered calls (ignored). Four 503 responses opened it; the next request returned the stale fallback price while the stub saw no new request (4 upstream hits before and after). Four 200 responses delayed by 400 ms opened it too, with slowCallRate=100.0 and failureRate=0.0. After the wait the breaker was HALF_OPEN before any call (the automatic transition), and two successful probes closed it.

The Actuator view while the circuit is open
{
  "circuitBreakers": {
    "pricing": {
      "bufferedCalls": 4,
      "failedCalls": 4,
      "failureRate": "100.0%",
      "failureRateThreshold": "50.0%",
      "notPermittedCalls": 1,
      "slowCallRate": "0.0%",
      "slowCallRateThreshold": "70.0%",
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "state": "OPEN"
    }
  }
}

Source: Resilience4j: Spring Boot 2 & 3 (Actuator endpoints; the output was captured from a Boot 4.1.1 run and reformatted).

A few follow-ups worth making after the first production week: watch FAILURE_RATE_EXCEEDED and SLOW_CALL_RATE_EXCEEDED counts to see which trigger fires (see Observability); start a new instance in METRICS_ONLY first if you are unsure of the thresholds; and revisit the window when the traffic profile changes, because the best window length follows the request rate. The production checklist is in Production Tuning & Anti-Patterns.

Common pitfalls

Table 11. CircuitBreaker pitfalls
Pitfall What to do instead

Leaving minimumNumberOfCalls at 100 with a small window and low traffic

The breaker never has enough data to open. Set it to what a window of your real traffic contains, and remember it is capped to slidingWindowSize for COUNT_BASED.

A long TIME_BASED window under heavy traffic

Old healthy calls dominate the rates, so the breaker opens late (15 s for a total outage with a 30 s window at 20 calls per second). Shorten the window.

Expecting the slow-call rate to work with the defaults

slowCallRateThreshold defaults to 100, so every call must be slow. Lower it, and set slowCallDurationThreshold above the p99 latency.

Listing recordExceptions and forgetting that everything else becomes a success

A bug in your code (NullPointerException) then counts as healthy. Prefer a deliberate recordFailurePredicate, and list ignorable business exceptions in ignoreExceptions.

Recording client errors (4xx) as failures

A 404 or a 400 says nothing about the dependency’s health. Ignore them, and decide explicitly about 429.

Setting ignoreExceptionsPrecedenceEnabled in YAML

There is no such Spring Boot property in 2.4.0; the entry is silently ignored. The state machine already lets the ignore list win. Use a CircuitBreakerConfigCustomizer only if code reads the record predicate directly.

Misspelling an exception class in recordExceptions

The application fails at startup with a ClassNotFoundException; read the binder message. Not a runtime surprise, but fix it before deploying.

Combining waitDurationInOpenState and waitIntervalFunctionInOpenState

build() throws IllegalStateException. Choose one; in Spring Boot, use the enableExponentialBackoff or enableRandomizedWait keys next to waitDurationInOpenState.

Waiting for the OPEN state to end by polling getState()

Without automaticTransitionFromOpenToHalfOpenEnabled the state changes only when a call arrives, so getState() can say OPEN long after the wait.

HALF_OPEN stuck because probes never complete

With maxWaitDurationInHalfOpenState at its default of 0 the breaker waits indefinitely. Set a limit and transitionToStateAfterWaitDuration.

Expecting the breaker to limit concurrency

It does not; any number of threads can be in a CLOSED breaker. Add a Bulkhead.

Retrying CallNotPermittedException

It is a quick local rejection; retrying it just burns attempts. Keep Retry outside the breaker and list the exception in the retry’s ignoreExceptions.

Forgetting that Retry outside the breaker multiplies recorded calls

With the default aspect order, three attempts are three recorded failures (a run showed three ERROR events for one failing request). Size minimumNumberOfCalls and the window with that in mind; see the aspect-order page.

A FORCED_OPEN breaker left behind after an incident

It rejects calls until someone closes it (the Actuator CLOSE operation, transitionToClosedState() or reset()). Log and alert on STATE_TRANSITION events to FORCED_OPEN.

Exposing the circuitbreakers write operation

One unauthenticated POST can disable or force-open a breaker. Expose the endpoint on a protected management port only.

Sharing one breaker across unrelated backends

A failing backend opens the circuit for all. One breaker per remote dependency.

Using the circuit-breaker health as a liveness probe

An open circuit means the dependency is down, not that your pod should be restarted. Keep it out of liveness (see Observability).

What changed recently

  • 1.x to 2.0.0. Resilience4j 2.0.0 removed the Vavr dependency, and required Java 17. The deprecated ringBufferSizeInClosedState and ringBufferSizeInHalfOpenState properties were removed in 2.0.1 in favour of slidingWindowSize and permittedNumberOfCallsInHalfOpenState. 2.0.1 also added the Spring Boot 3 starter.

  • 2.1.0. The release notes list recordResultPredicate as configurable from Spring Boot properties (issue 1955).

  • 2.2.0. A StackOverflowError from an infinite loop in the open state’s permission check was fixed (issue 2038).

  • 2.3.0. A custom Clock can be passed to CircuitBreakerConfig without internal APIs (issues 2234 and 2239), the sliding window got a lock-free implementation and the locks became ReentrantLocks so they do not pin virtual threads (issues 2241 and 2232), and the release notes mention a circular-reference StackOverflowError in configuration handling (issue 2190). In 2.4.0 only a baseConfig that names itself is reported ("Circular reference detected"); a longer cycle still ends in a StackOverflowError.

  • 2.4.0. initialState (PR 2268), transitionToStateAfterWaitDuration (PR 2290), the opt-in ignoreExceptionsPrecedenceEnabled builder flag (PR 2304), the guard that rejects TIME_BASED with LOCK_FREE and a window below 2 (PR 2358), a fix to the default constant of waitIntervalFunctionInOpenState (PR 2402), and the Spring Boot 4 starter. The release notes for 2.4.0 itself are only in the GitHub release: RELEASENOTES.adoc at the 2.4.0 tag stops at 2.3.0.

  • Documentation versus code in 2.4.0. The readme.io page still omits slidingWindowSynchronizationStrategy, transitionToStateAfterWaitDuration, initialState, ignoreExceptionsPrecedenceEnabled and clock, and its record versus ignore wording (ignore wins) matches how the state machine behaves, while the 2.4.0 builder Javadoc describes a "legacy" record-wins behaviour that only shows in the record predicate object. ignoreExceptionsPrecedenceEnabled is not a Spring Boot property in 2.4.0.

  • Spring Boot 2 to 3 to 4. The property keys under resilience4j.circuitbreaker are the same. What changed is the starter (resilience4j-spring-boot3, then resilience4j-spring-boot4), the AspectJ starter requirement, and the Actuator health package names; see Migration & What’s Changed.