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.
(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
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.
| State | Calls | Outcomes | Events published |
|---|---|---|---|
|
permitted |
recorded in the sliding window; may open the circuit |
all ( |
|
rejected with |
late results of calls that started while |
all, including |
|
only the first |
recorded in a separate count-based window of that size; once all probes have finished the breaker closes or reopens |
all |
|
permitted |
recorded and the rates are computed, but the breaker never opens |
all; |
|
permitted |
not recorded: the metrics stay empty |
only |
|
always rejected (counted as not permitted) |
none |
only |
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.
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
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.
// 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).
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.
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 theFAILURE_RATE_EXCEEDEDevent, and switch toCLOSEDwhen 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.
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:
| From | To |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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.
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
Source: Resilience4j v2.4.0 release notes
(PR 2268, initial state) and
CommonCircuitBreakerConfigurationProperties at v2.4.0.
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.
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"}
Source: Resilience4j: Spring Boot 2 & 3 (Actuator
endpoints) and the
CircuitBreakerEndpoint at v2.4.0.
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.
COUNT_BASED |
TIME_BASED |
|
|---|---|---|
|
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 |
|
capped to |
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:
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:
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 aReentrantLock. It does not allocate extra objects per call, but threads can block each other for the short time an update takes. The lock is aReentrantLock, not asynchronizedblock, so it does not pin virtual threads (a change made in 2.3.0, issue 2232). -
LOCK_FREEupdates 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 callsas a percentage; comparefailureRateThreshold; -
the slow-call rate is
slow calls / total calls; a call is slow when it takes longer thanslowCallDurationThreshold(a duration exactly equal to the threshold is not slow), whether it succeeded or failed; compareslowCallRateThreshold.
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.
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.
| Property | Default | Builder method | Meaning and notes |
|---|---|---|---|
|
|
|
Open at or above this failure rate. Valid range 1 to 100. |
|
|
|
Open at or above this slow-call rate. Valid range 1 to 100. The default never fires before every call is slow. |
|
|
|
A call longer than this counts as slow. Spring Boot reads a |
|
|
|
|
|
|
|
Calls ( |
|
|
|
Calls needed before any rate is computed (otherwise |
|
|
|
|
|
|
|
Probe calls in |
|
|
|
Longest stay in |
|
|
|
|
| Property | Default | Builder method | Meaning and notes |
|---|---|---|---|
|
|
|
Fixed wait before |
|
fixed 60 s |
|
Wait as a function of the number of consecutive openings (exponential, randomized, custom). Do not combine with
|
|
|
|
Move to |
|
|
|
State of a newly created breaker. New in 2.4.0 (PR 2268). |
|
|
|
|
|
|
|
Time source for the open-state wait and the time-based window. Java only. Added in 2.3.0. |
| Property | Default | Builder method | Meaning and notes |
|---|---|---|---|
|
empty (every exception) |
|
Exception classes (and subclasses) recorded as failures. If set, every other exception counts as a success. |
|
empty |
|
Exception classes neither a failure nor a success: not recorded at all. |
|
|
|
Custom rule. Spring Boot takes a class name of a |
|
|
|
Custom ignore rule. Spring Boot takes a class name, as above. |
|
|
|
Count a returned value as a failure (for example a response with status 503). Spring Boot key since 2.1.0. |
|
|
|
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. |
| Key | Default | Meaning |
|---|---|---|
|
none |
Name of a |
|
|
Include the instance in the |
|
|
Report |
|
|
Size of the ring buffer behind |
|
off, library default 1.5, none |
Exponential open-state wait based on |
|
off, library default 0.5 |
Randomized open-state wait based on |
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:
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();
Source: CircuitBreakerConfig.java at v2.4.0.
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:
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));
}
}
Source: Resilience4j: Spring Boot 2 & 3 (customizers) and the
CircuitBreakerConfigCustomizer source at v2.4.0.
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.
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):
-
If the ignore predicate matches (
ignoreExceptionsorignoreExceptionPredicate), the call is ignored: it is not recorded as a failure or a success, the permission is released, and anIGNORED_ERRORevent is published. -
Otherwise, if the record predicate matches (
recordExceptionsorrecordFailurePredicate; by default every exception), the call is recorded as a failure. -
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 aNullPointerExceptionthrown 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.
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:
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 ofignoreExceptionssays the same: ignore has priority. -
The 2.4.0 Javadoc of
ignoreExceptionsPrecedenceEnabledsays that when it isfalse, "the legacy behavior is used whererecordExceptionstakes precedence for backward compatibility". Issue 2295 and pull request 2304, which added the flag, describe the problem the same way: a subclass inignoreExceptionswhose superclass is inrecordExceptions"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_ERRORevent with and without the flag: forrecordExceptions: [IOException]andignoreExceptions: [FileNotFoundException], aFileNotFoundExceptionwas ignored in both modes. -
What the flag changes is the record predicate as an object:
config.getRecordExceptionPredicate().test(fnf)returnstrueby default andfalsewithignoreExceptionsPrecedenceEnabled(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.
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
Source: Resilience4j: CircuitBreaker, and
CircuitBreakerConfig.Builder.ignoreExceptionsPrecedenceEnabled at v2.4.0.
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.
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:
resilience4j:
circuitbreaker:
instances:
pricing:
waitDurationInOpenState: 20s
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
exponentialMaxWaitDurationInOpenState: 2m
isbn-metadata:
waitDurationInOpenState: 10s
enableRandomizedWait: true
randomizedWaitFactor: 0.25
Source: Resilience4j: Spring Boot 2 & 3 and the
CommonCircuitBreakerConfigurationProperties source at v2.4.0.
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.
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
Source: Resilience4j: CircuitBreaker and Resilience4j: Bulkhead.
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.
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
}
Source: Resilience4j: CircuitBreaker and the
CallNotPermittedException source at v2.4.0.
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.
| Event type | Published when | Payload |
|---|---|---|
|
a call succeeded (or a non-recorded exception counted as a success) |
elapsed duration |
|
a call failed and was recorded |
elapsed duration, the |
|
an ignored exception |
elapsed duration, the |
|
a call was rejected |
breaker name |
|
the state changed (always published, even in |
|
|
|
breaker name |
|
the failure rate reached its threshold |
the failure rate |
|
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).
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:
| Method | Returns |
|---|---|
|
failure percentage; |
|
slow-call percentage; |
|
calls in the window |
|
successes and failures in the window |
|
slow calls in the window, and the split |
|
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.
Source: Resilience4j: CircuitBreaker (getMetrics) and
the CircuitBreaker.Metrics interface at v2.4.0.
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_BASEDwindow 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.
slowCallDurationThresholdis 800 ms, twice the p99 and well below the 2-second client timeout, andslowCallRateThresholdis 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).
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.
@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.
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).
| 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.
{
"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
| Pitfall | What to do instead |
|---|---|
Leaving |
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 |
A long |
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 |
|
Listing |
A bug in your code ( |
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 |
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 |
Misspelling an exception class in |
The application fails at startup with a |
Combining |
|
Waiting for the |
Without |
|
With |
Expecting the breaker to limit concurrency |
It does not; any number of threads can be in a |
Retrying |
It is a quick local rejection; retrying it just burns attempts. Keep |
Forgetting that |
With the default aspect order, three attempts are three recorded failures (a run showed three |
A |
It rejects calls until someone closes it (the Actuator |
Exposing the |
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
ringBufferSizeInClosedStateandringBufferSizeInHalfOpenStateproperties were removed in 2.0.1 in favour ofslidingWindowSizeandpermittedNumberOfCallsInHalfOpenState. 2.0.1 also added the Spring Boot 3 starter. -
2.1.0. The release notes list
recordResultPredicateas configurable from Spring Boot properties (issue 1955). -
2.2.0. A
StackOverflowErrorfrom an infinite loop in the open state’s permission check was fixed (issue 2038). -
2.3.0. A custom
Clockcan be passed toCircuitBreakerConfigwithout internal APIs (issues 2234 and 2239), the sliding window got a lock-free implementation and the locks becameReentrantLocks so they do not pin virtual threads (issues 2241 and 2232), and the release notes mention a circular-referenceStackOverflowErrorin configuration handling (issue 2190). In 2.4.0 only abaseConfigthat names itself is reported ("Circular reference detected"); a longer cycle still ends in aStackOverflowError. -
2.4.0.
initialState(PR 2268),transitionToStateAfterWaitDuration(PR 2290), the opt-inignoreExceptionsPrecedenceEnabledbuilder flag (PR 2304), the guard that rejectsTIME_BASEDwithLOCK_FREEand a window below 2 (PR 2358), a fix to the default constant ofwaitIntervalFunctionInOpenState(PR 2402), and the Spring Boot 4 starter. The release notes for 2.4.0 itself are only in the GitHub release:RELEASENOTES.adocat 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,ignoreExceptionsPrecedenceEnabledandclock, 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.ignoreExceptionsPrecedenceEnabledis not a Spring Boot property in 2.4.0. -
Spring Boot 2 to 3 to 4. The property keys under
resilience4j.circuitbreakerare the same. What changed is the starter (resilience4j-spring-boot3, thenresilience4j-spring-boot4), the AspectJ starter requirement, and the Actuator health package names; see Migration & What’s Changed.