Spring Cloud 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. |
Spring Cloud CircuitBreaker (SCCB) is a thin abstraction: one CircuitBreaker interface with one method, run(supplier,
fallback), and a factory that hands out implementations backed by Resilience4j, Spring Retry or — new in the 5.0 line — Spring Framework 7’s retry support. It trades Resilience4j’s breadth (no Retry or RateLimiter decorator, no per-call
events) for a single call shape that survives a change of library, and it is what Spring Cloud Gateway, Spring Cloud
OpenFeign and the Spring Cloud HTTP service client integration use internally. This page was written against Spring Cloud
CircuitBreaker 5.0.3 (release train 2025.1.3), Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21,
and every example and every behaviour on it was compiled and run on 4 October 2026. The Spring Boot 3.5 line is served by
Spring Cloud CircuitBreaker 3.3.x, whose Resilience4j starter is the Boot 3 one, so the alignment work described below
is Boot 4 only; the Boot 3 side was not run here.
Four findings differ from what the reference documentation or the common wisdom says, and each has its own section:
-
the fallback receives an
ExecutionExceptioninstead of the real exception as soon asresilience4j-bulkheadis on the classpath, which is the case after aligning to Resilience4j 2.4.0 (the ExecutionException finding); -
YAML
configs.<name>entries beatfactory.configure(…)customizers, and a YAMLinstances.<name>entry makes the customizer for that name dead code (Precedence); -
the Framework Retry circuit breaker closes after the reset timeout, not the open timeout, with the default values (Framework Retry);
-
spring.cloud.circuitbreaker.resilience4j.enableGroupMeterFilteris only honoured in its camelCase spelling (Metrics and observation).
How Resilience4j itself is configured and annotated is covered in Circuit Breaker, Bulkhead and Time Limiter; this page only adds what SCCB does on top. The Gateway and OpenFeign integrations that build on this abstraction are in Gateway & OpenFeign, and Spring Framework’s own retry and concurrency annotations are in Spring Framework Native Resilience.
The abstraction
Everything lives in Spring Cloud Commons (org.springframework.cloud.client.circuitbreaker). A project that wants a
different library only swaps the starter.
| Type | What it does | Notes |
|---|---|---|
|
|
The two-argument |
|
|
The shortcut installs a fallback that throws |
|
|
Needs |
|
A functional interface with |
The unit of configuration: a |
|
Builds the implementation-specific configuration ( |
Passed to the |
Source: CircuitBreakerFactory at v5.0.3,
Customizer and the
Spring Cloud Commons reference.
run(supplier, fallback) in the Bookshelf scenario
In the running Bookshelf scenario, bookshelf-service asks pricing-service for the price of a book and degrades to
the list price when the call fails. With the abstraction, the call site contains no Resilience4j type at all:
package com.example.bookshelf.pricing;
import java.math.BigDecimal;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import org.springframework.cloud.client.circuitbreaker.CircuitBreaker;
import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory;
import org.springframework.stereotype.Service;
@Service
public class PricingService {
static final BigDecimal LIST_PRICE = new BigDecimal("19.99");
private final CircuitBreaker breaker;
private final PricingClient client;
public PricingService(CircuitBreakerFactory<?, ?> factory, PricingClient client) {
// id "pricing" names the circuit breaker, group "pricing-service" names the bulkhead and the meter tag
this.breaker = factory.create("pricing", "pricing-service");
this.client = client;
}
public Quote quote(String isbn) {
return breaker.run(
() -> new Quote(client.fetch(isbn), "pricing-service"),
throwable -> listPrice(unwrap(throwable)));
}
private static Quote listPrice(Throwable cause) {
String why = cause instanceof CallNotPermittedException ? "circuit open"
: cause instanceof TimeoutException ? "timeout"
: "error";
return new Quote(LIST_PRICE, "list price (" + why + ")");
}
// With resilience4j-bulkhead on the classpath the thread-pool bulkhead wraps failures in an ExecutionException
private static Throwable unwrap(Throwable throwable) {
return throwable instanceof ExecutionException && throwable.getCause() != null ? throwable.getCause() : throwable;
}
}
Source: Spring Cloud Commons: Spring Cloud Circuit Breaker (the create(…).run(…) shape); the unwrap helper is explained in
the ExecutionException finding.
The supporting types are a record Quote(BigDecimal amount, String source) and an interface
PricingClient { BigDecimal fetch(String isbn); } standing for the HTTP client of pricing-service
(see Programmatic Usage for real ones). Run against a stub, the
four situations produced:
| Stub behaviour | quote("9780132350884") |
|---|---|
returns |
|
throws |
|
sleeps 1.2 s (limit 800 ms) |
|
enough further failures to open the breaker, then any call |
|
Source: compiled and run for this page against Spring Cloud CircuitBreaker 5.0.3 and Resilience4j 2.4.0; configuration in Customizer beans and the worked example.
Ids, groups and what they name
create(id) is create(id, id): the group defaults to the id. The two names drive different things, which is why a
group is useful — several breakers share one bulkhead, one executor and one meter tag:
| Resilience4j object | Looked up by | Notes |
|---|---|---|
|
the id |
One state machine per id, tagged |
|
the id, then the group, then a new instance named after the id |
A YAML instance named like the group is used by every breaker of that group. |
|
the group |
One bulkhead per group, shared by all ids of the group. |
|
the group ( |
|
Micrometer tag |
the group |
Source: Resilience4JCircuitBreakerFactory at v5.0.3 and
Resilience4JCircuitBreaker; verified in the run:
the bulkhead registry held pricing-service while the circuit-breaker registry held pricing, and create("slow") produced a
time limiter, a bulkhead and a breaker all named slow.
Starters
Spring Cloud 2025.1.3 manages Spring Cloud CircuitBreaker 5.0.3. The starter artifacts of the 5.0 line are:
Artifact (group org.springframework.cloud) |
Provides | What it pulls |
|---|---|---|
|
|
|
|
|
The above plus |
|
|
|
|
|
Still published at 5.0.3; Spring Retry is in maintenance, see Spring Framework Native Resilience. |
Source: Spring Cloud CircuitBreaker: Starters,
Framework Retry circuit breakers and the
starter POMs of 5.0.3 on Maven Central (dependency lists read from the downloaded POMs and confirmed with mvn dependency:tree).
Each starter registers its factory with @ConditionalOnMissingBean(CircuitBreakerFactory.class), so only one
implementation wins when two starters are present; keep one CircuitBreakerFactory per application. The reactive factory
is a separate bean type (ReactiveCircuitBreakerFactory), so the Resilience4j blocking and reactive starters coexist.
Spring Cloud 2025.1 (Oakwood) is declared for Spring Boot 4.0.x on the release-train page. All examples here ran on Boot 4.1.1 with 2025.1.3 and worked, but that pairing is outside the table; watch for the next train before relying on it.
Version alignment
SCCB 5.0.x was built against Resilience4j 2.3.0 and its Spring Boot 3 starter. On Spring Boot 4 that pairing is
inconsistent: Resilience4j 2.4.0 is the first release with resilience4j-spring-boot4.
| Artifact | Version |
|---|---|
|
5.0.3 |
|
2.3.0 (a Boot 3 starter on a Boot 4 application) |
|
2.3.0 |
|
not present (optional, and |
Source: mvn dependency:tree of the unaligned project; the spring-cloud-circuitbreaker-dependencies 5.0.3 POM, which imports
resilience4j-bom with resilience4j.version 2.3.0; the v5.0.3 tag.
That set works for the SCCB API — the factory, the Resilience4J* registries and the metrics were all created and the
calls ran on Boot 4.1.1 — but it is the Boot 3 starter on Boot 4, not something Resilience4j tests. The milestone
spring-cloud-circuitbreaker-dependencies 5.1.0-M1 moves to Resilience4j 2.4.0 and declares resilience4j-spring-boot4
explicitly, which confirms the direction; until 5.1 is a release, align by hand.
The alignment recipe
The recipe was verified with mvn dependency:tree (a pure 2.4.0 tree) and with the tests on this page:
<properties>
<spring-cloud.version>2025.1.3</spring-cloud.version>
<resilience4j.version>2.4.0</resilience4j.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- must come BEFORE spring-cloud-dependencies: the first import wins -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-bom</artifactId>
<version>${resilience4j.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
<exclusions>
<exclusion>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- not in the BOM, so the version is explicit -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot4</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<!-- only needed for @CircuitBreaker and friends; SCCB itself does not use the aspects -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
</dependencies>
Source: Resilience4j: Spring Boot 2 & 3, the
resilience4j-spring-boot4 is missing from the BOM issue (#2427) and the
Getting Started page for the Boot 4 starter; version numbers verified on Maven Central on 2026-10-04.
Three details decide whether it works:
-
Order of the imports. Overriding the
resilience4j.versionproperty alone does not work: the importedspring-cloud-dependenciesBOM still resolves 2.3.0 for the Resilience4j artifacts and the tree ends up mixed. Importingresilience4j-bomfirst makes its versions win. -
resilience4j-spring-boot4is not in the BOM (also true ofresilience4j-hedge), so it carries an explicit version. -
resilience4j-spring-boot3must be excluded. Resilience4j 2.4.0 ships a startup verifier in both starters, andresilience4j-spring-boot32.4.0 on Boot 4 refuses to start with "only compatible with Spring Boot 3.x". The excluded 2.3.0 artifact has no verifier, which is the only reason the unaligned set starts.
The same effect in Gradle Kotlin DSL (not run here, Gradle was not installed on the machine used; Gradle resolves to the highest version on conflict, so the platform order matters less than in Maven):
dependencies {
implementation(platform("io.github.resilience4j:resilience4j-bom:2.4.0"))
implementation(platform("org.springframework.cloud:spring-cloud-dependencies:2025.1.3"))
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") {
exclude(group = "io.github.resilience4j", module = "resilience4j-spring-boot3")
}
implementation("io.github.resilience4j:resilience4j-spring-boot4:2.4.0")
implementation("org.springframework.boot:spring-boot-starter-aspectj")
}
Source: Gradle: Sharing dependency versions between projects (platforms); unverified by a run.
What alignment changes
Two consequences, one visible and one easy to miss:
-
A WARN at startup.
Resilience4JAutoConfigurationandResilence4JMetricsOrderingAutoConfigurationdeclare their ordering againstio.github.resilience4j.springboot3…classes, which do not exist inresilience4j-spring-boot4(the package there isio.github.resilience4j.springboot…). Spring logsFailed to introspect meta-annotation @AutoConfigureAfter … TypeNotPresentExceptionand ignores those orderings. Everything verified here still worked — the group meter tags, the registries and the factory were all present — but the ordering guarantee the SCCB authors intended is silently lost. -
resilience4j-bulkheadarrives.resilience4j-micrometer2.4.0 depends onresilience4j-bulkhead, so the aligned tree contains it and SCCB starts wrapping every call in a bulkhead. That is the cause of the next finding.
The fallback receives an ExecutionException
A finding from the verification notes: with SCCB 5.0.3 on Resilience4j 2.3.0,
factory.create("x").run(failing, t → …) passes the original IllegalStateException to the fallback; after aligning to
2.4.0 the same fallback receives an ExecutionException wrapping it. The change is not in Resilience4j 2.4.0 and
not in the TimeLimiter (the 2.4.0 change in decorateCompletionStage, pull request 2374, is unrelated to the Future
path SCCB uses). The layer that wraps is SCCB’s Resilience4jBulkheadProvider.decorateCallable, and it only exists when
resilience4j-bulkhead is on the classpath.
// Resilience4JCircuitBreaker.run(...), executorService != null and a bulkhead provider present
Callable<T> timeLimitedCall = timeLimiter
.map(tl -> TimeLimiter.decorateFutureSupplier(tl, futureSupplier)) // unwraps ExecutionException
.orElse(() -> futureSupplier.get().get());
Callable<T> bulkheadCall = bulkheadProvider.decorateCallable(this.groupName, tags, timeLimitedCall);
Callable<T> circuitBreakerCall = CircuitBreaker.decorateCallable(defaultCircuitBreaker, bulkheadCall);
return getAndApplyFallback(circuitBreakerCall, fallback);
// Resilience4jBulkheadProvider.decorateCallable(...), thread-pool branch (the default)
return () -> threadPoolBulkhead.decorateCallable(callable).get().toCompletableFuture().get(); // throws ExecutionException
Source: Resilience4JCircuitBreaker and
Resilience4jBulkheadProvider at v5.0.3 (excerpts, abbreviated); the
v2.4.0 TimeLimiterImpl (decorateFutureSupplier rethrows the cause).
The thread-pool bulkhead runs the already time-limited callable on its own thread and hands back a
CompletableFuture; the provider then blocks on Future.get(), which by contract wraps a failure in an
ExecutionException. The experiment isolates the variable:
| Classpath | Bulkhead provider bean | What the fallback receives |
|---|---|---|
aligned: Resilience4j 2.4.0 ( |
yes |
|
unaligned: SCCB-pinned 2.3.0, no |
no |
|
unaligned plus |
yes |
|
aligned, with |
no |
|
aligned with |
no |
|
aligned with |
yes (semaphore) |
|
Source: run for this page with ExecTest, SemaphoreThrowableTest and BulkheadTest$NoBulkhead against four copies of the same project (pure 2.4.0, the unaligned
SCCB pin, the pin plus bulkhead 2.3.0, and 2.4.0 without bulkhead).
So the layer is the thread-pool bulkhead, the trigger is the presence of resilience4j-bulkhead, and 2.4.0 matters only
because it makes that module arrive transitively. What the wrapping touches and what it does not:
-
The fallback’s
Throwableis wrapped: aTimeoutExceptionfrom the time limiter arrives asExecutionException > TimeoutException;IllegalStateExceptionasExecutionException > IllegalStateException. The same is true of theThrowablepassed to an@HttpServiceFallbackmethod that takes one. -
The circuit breaker is not fooled: Resilience4j’s state machine unwraps
ExecutionExceptionandCompletionExceptionbefore applying the record and ignore predicates. WithignoreExceptions: java.lang.IllegalArgumentExceptionthe exception counted as ignored (zero failed, zero successful) on the thread-pool bulkhead and on the semaphore bulkhead alike. -
CallNotPermittedExceptionandBulkheadFullExceptionare not wrapped: the breaker is the outermost layer, and the semaphore bulkhead and a full thread-pool bulkhead throw before any future exists. -
NoFallbackAvailableExceptionfromrun(supplier)wraps whatever the fallback would have received, so its cause is theExecutionExceptionand its cause’s cause is the real exception.
The portable fix is to unwrap in the fallback, as PricingService.unwrap above does. Alternatives are to switch to the
semaphore bulkhead (enable-semaphore-default-bulkhead: true), to exclude resilience4j-bulkhead, or to turn the
wrapper off with spring.cloud.circuitbreaker.bulkhead.resilience4j.enabled=false; each also removes the second thread
hop that a thread-pool bulkhead adds. The classpath-sensitivity is the real hazard: a dependency update that adds
resilience4j-bulkhead changes the type every fallback sees, without a compile error.
How run(supplier, fallback) decorates the supplier
The blocking Resilience4JCircuitBreaker.run assembles the layers in a fixed order; the figure shows them for the
default configuration (executor, time limiter and thread-pool bulkhead present).
The branches, from Resilience4JCircuitBreaker.run and the factory:
| Situation | Decoration (outermost first) |
|---|---|
Default: executor, TimeLimiter, bulkhead provider present |
|
No |
|
|
the same, with |
|
|
Source: Resilience4JCircuitBreaker at v5.0.3; shapes confirmed by thread names
and timing in TimeLimiterTest (with disable-thread-pool=true, a 1.5 s supplier succeeded on a thread named bulkhead-slow-N).
The supplier never runs on the calling thread in the default shape: it runs on a pool thread of the factory’s
ExecutorService (a cached thread pool unless you set configureExecutorService). That has consequences for
ThreadLocal-based context — security context, MDC, transaction — which does not propagate; the documented remedy
is a context-aware executor (see the customizer section below).
TimeLimiter is on by default
Every SCCB-created breaker is also time-limited. Without configuration the limit is
TimeLimiterConfig.ofDefaults(): 1 second with cancelRunningFuture true. A supplier that takes 1.5 s therefore
fails with a timeout on a default setup even though nobody asked for a time limit:
| Setting | Result | Time limiter in the registry |
|---|---|---|
defaults |
fallback after about 1 s, |
|
|
completes after 1.5 s |
|
|
fallback after about 0.3 s |
|
|
completes after 1.5 s |
none created |
|
completes; another id ( |
none for |
|
completes: the map is checked for the id, then the group |
none |
Source: Spring Cloud CircuitBreaker: Circuit Breaker Properties Configuration (Disabling the TimeLimiter) and
Resilience4JConfigurationProperties;
every row run in TimeLimiterTest on 4 October 2026.
The properties live under spring.cloud.circuitbreaker.resilience4j and bind relaxed (kebab-case in YAML):
| Property | Default | Effect |
|---|---|---|
|
|
Turns the |
|
empty |
|
|
|
No |
|
|
Use a semaphore instead of a thread-pool bulkhead when nothing says otherwise. See below. |
|
|
Adds the |
|
|
Value of that tag for meters of breakers not created through the factory. |
Source: Resilience4JConfigurationProperties at v5.0.3 and
Resilience4JAutoConfiguration
(the @ConditionalOnProperty switches: spring.cloud.circuitbreaker.resilience4j.enabled, .blocking.enabled, .reactive.enabled,
spring.cloud.circuitbreaker.bulkhead.resilience4j.enabled, spring.cloud.circuitbreaker.resilience4j.micrometer.enabled).
Two behaviours are worth knowing beyond the table. First, SCCB logs once per id when no time-limiter configuration exists
for the id or its group (No timeLimiterConfig found for 'x' in time limiter registry, using …): at WARN when the registry has no
default configuration and at debug otherwise. The Boot registry always has a default configuration, and no WARN appeared in
any run here, so in practice the default silently applies. Second, cancelRunningFuture defaults to true, so a timeout
interrupts the pool thread running the supplier: a supplier that ignores interruption keeps running and keeps its pool
thread.
The reactive factory uses Mono.timeout(duration) instead of a future, so the fallback receives a plain
TimeoutException (the run printed fallback:TimeoutException after about 1 s) and the breaker is told about it
explicitly. How the TimeLimiter itself works is in Time Limiter.
Customizer beans
The factory is configured by Customizer<Resilience4JCircuitBreakerFactory> beans, applied once when the factory is
built. The reactive factory has its own type, Customizer<ReactiveResilience4JCircuitBreakerFactory>.
| Method | What it does |
|---|---|
|
The function applied to every id that has no |
|
One configuration (circuit breaker plus time limiter) for each listed id. |
|
A hook that receives the Resilience4j |
|
Wraps a customizer so it runs once per key, typically |
|
The executor for |
|
The executor factory for |
|
Replace the registry (Boot already supplies the auto-configured one). |
Source: Spring Cloud CircuitBreaker: Default Configuration,
Specific Circuit Breaker Configuration and
Resilience4JCircuitBreakerFactory at v5.0.3.
The Bookshelf customizers, in a profile so they only apply where wanted:
package com.example.bookshelf.pricing;
import java.time.Duration;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4jBulkheadProvider;
import org.springframework.cloud.client.circuitbreaker.Customizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration(proxyBeanMethods = false)
@Profile("bookshelf")
public class BookshelfCircuitBreakerConfig {
private static final Logger log = LoggerFactory.getLogger(BookshelfCircuitBreakerConfig.class);
@Bean
Customizer<Resilience4JCircuitBreakerFactory> circuitBreakers() {
return factory -> {
// used for every id that has no more specific configuration
factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.circuitBreakerConfig(CircuitBreakerConfig.custom()
.slidingWindowSize(20)
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(20))
.build())
.timeLimiterConfig(TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(2))
.build())
.build());
// time limit for the pricing id: it must answer within 800 ms
// (its circuit-breaker settings live in application.yml, see below)
factory.configure(builder -> builder
.circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
.timeLimiterConfig(TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofMillis(800))
.build()), "pricing");
// runs on every create(...).run(...), so guard it with Customizer.once
factory.addCircuitBreakerCustomizer(Customizer.once(
breaker -> breaker.getEventPublisher().onStateTransition(
event -> log.info("breaker {} {}", event.getCircuitBreakerName(), event.getStateTransition())),
io.github.resilience4j.circuitbreaker.CircuitBreaker::getName), "pricing");
};
}
@Bean
Customizer<Resilience4jBulkheadProvider> bulkheads() {
return provider -> provider.configure(builder -> builder
.bulkheadConfig(BulkheadConfig.custom()
.maxConcurrentCalls(10)
.maxWaitDuration(Duration.ZERO)
.build()), "pricing-service");
}
}
Source: Spring Cloud CircuitBreaker: Default Configuration and Specific Bulkhead Configuration; compiled and run.
addCircuitBreakerCustomizer is the sharp one. The customizer is stored per id and invoked from run(…) each time
the breaker runs, so a customizer that registers an event listener adds one more listener on every call. The run counted
six invocations for six calls of a plain customizer and one for the same customizer wrapped in
Customizer.once(…, CircuitBreaker::getName). In the example above the state-transition log line was emitted once per
transition, not once per call times transitions.
To run suppliers on a context-aware executor, give the factory one:
@Bean
Customizer<Resilience4JCircuitBreakerFactory> bookshelfExecutor() {
return factory -> factory.configureExecutorService(
Executors.newFixedThreadPool(8, runnable -> new Thread(runnable, "bookshelf-sccb-worker")));
}
Source: Spring Cloud CircuitBreaker: Customizing The ExecutorService (shows ContextAwareScheduledThreadPoolExecutor); the thread-name variant was run and the supplier of create("w1") ran on bookshelf-sccb-worker
while create("w2", "grp") ran on a pool created for the group. In a real application close the executor on shutdown.
Precedence
The reference states that property configuration beats customizers and that the order is method(id), service(group), global default. The experiment gives a more exact ladder. Each row was a separate id in one application with the Bookshelf customizers plus YAML at the same time:
| Sources present for the id | Winner | Window |
|---|---|---|
YAML |
the YAML instance |
66 (YAML), customizer 22 ignored |
YAML |
the YAML config |
55 (YAML), customizer 22 ignored |
YAML |
the YAML group config |
88 |
YAML |
the instance, built from |
44 |
only a customizer |
the customizer |
22 |
nothing for the id; a customizer |
the default function |
11 (the YAML |
nothing for the id, no |
YAML |
33 |
YAML |
nobody: it is not looked up for the circuit breaker |
the default (11) |
Source: run for this page (PrecedenceTest) on SCCB 5.0.3 / Resilience4j 2.4.0, matching
Resilience4JCircuitBreakerFactory.create and the
properties-configuration reference.
The ladder reads: instance YAML, then named YAML configs (id, then group), then configure(…) customizers, then
the default function (a customizer, or YAML configs.default when none). The issue’s shorthand — instance YAML over
customizers over shared configs — is right at the ends and wrong in the middle: a YAML configs.<id> or
configs.<group> entry also beats a customizer. The time limiter follows the same pattern with one difference: its
instance lookup also tries the group name (a YAML timelimiter.instances.pricing-service was applied to
create("quote", "pricing-service"), no instance named quote was created), while the circuit breaker’s does not.
The practical rule is one source per id. The first Bookshelf draft configured pricing both with
factory.configure(…, "pricing") and in YAML; the YAML instance won silently and the customizer’s sliding window
of 10 was ignored (the registry showed 100). The example above therefore keeps the circuit-breaker settings of
pricing in YAML and uses the customizer only for what YAML has no instance for — the time limiter, since there is no
timelimiter.instances.pricing entry.
Worked example: the Bookshelf configuration
The YAML that completes the example (the one run for the output table above, activated with the bookshelf profile):
spring:
cloud:
circuitbreaker:
resilience4j:
enable-semaphore-default-bulkhead: true
disable-time-limiter-map:
reviews: true
resilience4j:
circuitbreaker:
configs:
default:
ignoreExceptions:
- java.lang.IllegalArgumentException
instances:
pricing:
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 40
waitDurationInOpenState: 30s
Source: Resilience4j: Spring Boot 2 & 3 and the
SCCB properties reference; run with BookshelfExampleTest.
Reading it with the rules above: pricing gets its window, threshold and wait from the YAML instance and its 800 ms
limit from the customizer; every other id gets the customizer default (2 s, window 20); the semaphore bulkhead from
the property is sized by the provider customizer (10 permits, no wait) for the pricing-service group; and the
reviews id, for a streaming call that must not be cut, has its time limiter disabled. The ignoreExceptions entry
in configs.default is where "unknown ISBN" client errors, thrown as IllegalArgumentException, stop counting as
failures. The run opened the breaker after the failures, logged one CLOSED to OPEN transition and answered
list price (circuit open) without calling the stub.
Bulkhead wrapping
When resilience4j-bulkhead is on the classpath, SCCB creates a Resilience4jBulkheadProvider bean and wraps every
call in a bulkhead named after the group. There is no opt-in per breaker, only the global kill switch
spring.cloud.circuitbreaker.bulkhead.resilience4j.enabled=false (run: zero provider beans and an unwrapped fallback
Throwable).
The choice between the two implementations is made per group, by these rules, in order (useSemaphoreBulkhead):
| Order | Rule |
|---|---|
1 |
A |
2 |
Otherwise, |
3 |
Otherwise, a semaphore |
4 |
Otherwise: thread pool, built from the registry’s default |
Source: Spring Cloud CircuitBreaker: Bulkhead pattern supporting and
Resilience4jBulkheadProvider.useSemaphoreBulkhead at v5.0.3; each rule exercised in BulkheadTest.
The run confirmed the table:
| Setting | Result for group pricing-service |
|---|---|
nothing |
|
|
semaphore, |
YAML |
semaphore; a second concurrent call got |
YAML |
thread pool (rule 1 beats the flag) |
|
thread pool (the config alone does not select an implementation) |
|
semaphore with the configured |
|
semaphore, because the customizer creates the bulkhead in the registry (rule 3) |
Source: run for this page (BulkheadTest) on SCCB 5.0.3 / Resilience4j 2.4.0, 8-CPU machine; defaults of the thread-pool bulkhead from
Bulkhead.
The provider has its own customizer type, Customizer<Resilience4jBulkheadProvider>: configureDefault(Function<String,
BulkheadConfiguration>) for all groups, configure(consumer, groups…) with a Resilience4jBulkheadConfigurationBuilder
holding both a BulkheadConfig and a ThreadPoolBulkheadConfig, addBulkheadCustomizer and
addThreadPoolBulkheadCustomizer for hooks on the Resilience4j objects. The example above used configure. The
trap in the table is addBulkheadCustomizer (and addThreadPoolBulkheadCustomizer): because the hook needs the object, the
provider creates it in the registry first, which changes the selection rule. A full thread-pool bulkhead (core 1, max 1,
queue 1, with the pool and queue occupied) rejected the next call with a plain BulkheadFullException.
The reactive provider, ReactiveResilience4jBulkheadProvider, supports only the semaphore bulkhead; the property
set to false there logs Ignoring 'spring.cloud.circuitbreaker.resilience4j.enableSemaphoreDefaultBulkhead=false' and the
semaphore is used anyway. That @Value placeholder is camelCase: the kebab-case spelling in YAML did not trigger the
warning in the run, so spell the reactive flag in camelCase. The Resilience4j side of both implementations is in
Bulkhead.
Metrics and observation
With Actuator and a MeterRegistry present, the starter binds Resilience4j’s tagged metrics for the factory’s registries and
adds one tag to all of them. Running with a Prometheus registry, the breaker pricing in group pricing-service produced:
| Meter | Tags |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Source: Spring Cloud CircuitBreaker: Collecting Metrics and
the Resilience4j Micrometer page; meter list printed by MetricsTest.
The behaviours around the tag:
-
The group meter filter adds
group=<defaultGroupTag>(defaultnone) to anyresilience4j.*meter that has nogrouptag — the meters of breakers created through the registry directly rather than through the factory. A directregistry.circuitBreaker("direct")showedgroup=none, anddefault-group-tag=ungroupedchanged that toungrouped. -
The camelCase pitfall. The filter bean is guarded by
@ConditionalOnPropertyon the camelCase namespring.cloud.circuitbreaker.resilience4j.enableGroupMeterFilter. Setting the kebab-caseenable-group-meter-filter=falsedid not disable it (thedirectbreaker was still taggedgroup=none); onlyenableGroupMeterFilter: falseremoved the tag. The other switches are single-word, so they are unaffected. -
Observation. When an
ObservationRegistrybean exists (Actuator provides one),create(…)returns anObservedCircuitBreakerthat wraps the supplier and the fallback in aspring.cloud.circuitbreakerobservation with the low-cardinality keyspring.cloud.circuitbreaker.type(supplierorfunction). Turn it off withspring.cloud.circuitbreaker.resilience4j.micrometer.enabled=false. -
Tagged metrics bean. The auto-configuration only registers its own
TaggedCircuitBreakerMetricswhen noTaggedCircuitBreakerMetricsPublisherbean exists, so a Resilience4j starter that already registers one is respected.
The metrics pipeline, health groups, dashboards and alerts are in Observability; the group tag is what lets a dashboard sum over a service instead of listing breakers.
Reactive circuit breakers
ReactiveCircuitBreakerFactory mirrors the blocking API with Mono and Flux:
@Service
public class RecommendationsService {
private final ReactiveCircuitBreaker breaker;
private final WebClient webClient;
public RecommendationsService(ReactiveCircuitBreakerFactory<?, ?> factory, WebClient.Builder builder) {
this.breaker = factory.create("recommendations", "recommendations-service");
this.webClient = builder.baseUrl("http://recommendations.example.invalid").build();
}
public Mono<String> recommend(String isbn) {
return breaker.run(
webClient.get().uri("/books/{isbn}/similar", isbn).retrieve().bodyToMono(String.class),
throwable -> Mono.just("[]"));
}
}
Source: Spring Cloud Commons: Spring Cloud Circuit Breaker (reactive section); the factory API was run with a failing Mono and a Mono.delay(1500ms):
the fallback received IllegalStateException for the first and TimeoutException after about 1 s for the second.
The reactive implementation decorates with CircuitBreakerOperator, Mono.timeout and onErrorResume, uses only the
semaphore bulkhead, and has no executor, so no ExecutionException wrapping. With both Resilience4j starters present,
the blocking and reactive factories are two beans of two types. More on the reactive Resilience4j operators in
Reactive, Kotlin & RxJava.
HTTP service clients and @HttpServiceFallback
Spring Cloud Commons 5.0 adds a circuit breaker to Spring Framework 7’s declarative HTTP service clients. If a
CircuitBreakerFactory bean exists and the HTTP service groups come from @ImportHttpServices, a
CircuitBreakerRestClientHttpServiceGroupConfigurer wraps every group’s HttpExchangeAdapter so that each exchange runs
as circuitBreaker.run(…). A parallel CircuitBreakerWebClientHttpServiceGroupConfigurer handles the reactive
WebClient groups. Both are switched off with spring.cloud.circuitbreaker.http-services.enabled=false or
spring.cloud.circuitbreaker.reactive-http-services.enabled=false. @HttpServiceFallback (since 5.0.0) declares what
the fallback is.
The rules, from HttpServiceFallback, CircuitBreakerConfigurerUtils and the run:
| Aspect | Behaviour |
|---|---|
The breaker |
One per HTTP service group: |
Where the annotation goes |
On a Spring bean class ( |
The fallback class |
Needs a public no-argument constructor; SCCB instantiates it and wraps it in a CGLIB proxy. Fallback methods must be public. |
Method matching |
Same method name as the HTTP interface method. First choice: the same parameter types. Second choice: a |
|
The HTTP interfaces the fallback applies to; omitted means a default for all services in the group. A per-service entry beats the default. |
|
The HTTP service group the fallback applies to; omitted or blank means a default for all groups. A per-group fallback beats the default; duplicate keys throw |
The |
Whatever the circuit breaker’s fallback receives: |
Source: HttpServiceFallback at v5.0.3 and
CircuitBreakerConfigurerUtils; behaviour run in HttpServiceFallbackTest against a JDK HttpServer that answered 503.
A Bookshelf HTTP interface, its group and fallback:
@HttpExchange("/prices")
public interface PricingApi {
@GetExchange("/{isbn}")
BigDecimal price(@PathVariable String isbn);
}
/** Fallback methods must be public and match the HTTP interface method name and parameters. */
@Component
@HttpServiceFallback(value = PricingApiFallback.class, service = PricingApi.class, group = "pricing")
public class PricingApiFallback {
public static final BigDecimal LIST_PRICE = new BigDecimal("19.99");
// Preferred form: same name and parameters as PricingApi.price(String)
public BigDecimal price(String isbn) {
return LIST_PRICE;
}
}
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "pricing", types = PricingApi.class)
public class HttpClientsConfig {
}
Source: Spring Framework: REST Clients (HTTP service groups) and
Spring Cloud Commons; the group’s base URL is
spring.http.serviceclient.pricing.base-url (a localhost stub in the run, example.invalid in real configuration).
With a 503 stub and slidingWindowSize: 2, minimumNumberOfCalls: 2 on the pricing instance, three calls returned 19.99 three
times, the stub was hit twice, and the breaker was OPEN after the second failure. A second interface in a group whose
fallback class declared price(Throwable cause, String isbn) received ExecutionException caused by HttpServerErrorException.ServiceUnavailable,
and an interface method with no matching fallback method raised NoFallbackAvailableException. The HTTP interface
mechanics are in Programmatic Usage; that page decorates the clients
with Resilience4j directly, and this integration is the alternative when the SCCB API is enough.
The Framework Retry implementation
Spring Cloud CircuitBreaker 5.0 adds an implementation that needs no Resilience4j: it is built on Spring Framework 7’s
RetryTemplate and RetryPolicy (org.springframework.core.retry). The starter is
spring-cloud-starter-circuitbreaker-framework-retry, and its auto-configuration activates when RetryTemplate is on the
classpath. It is modeled on Spring Retry’s CircuitBreakerRetryPolicy: the circuit counts a whole invocation with all
its retries exhausted as one failure, and opens after that single failure.
| Option | Default | Meaning |
|---|---|---|
|
|
Used while the circuit is closed or half-open. |
|
20 s |
How long the circuit stays open before one test call is allowed (half-open). |
|
5 s |
How long after the last failure the state resets to closed. |
Source: Spring Cloud CircuitBreaker: Framework Retry and
CircuitBreakerRetryPolicy at v5.0.3; RetryPolicy defaults from the Spring Framework 7.0.9 sources (see
Spring Framework Native Resilience).
@TestConfiguration
static class Cfg {
@Bean
Customizer<FrameworkRetryCircuitBreakerFactory> customizer() {
return factory -> factory.configure(builder -> builder
.retryPolicy(RetryPolicy.builder().maxRetries(2).delay(Duration.ofMillis(10)).build())
.openTimeout(Duration.ofMillis(600))
.resetTimeout(Duration.ofSeconds(30)), "pricing");
}
}
// usage is the same API as before
CircuitBreaker breaker = factory.create("pricing");
String result = breaker.run(() -> remote.call(), throwable -> "fallback");
Source: Spring Cloud CircuitBreaker: Framework Retry (default and specific configuration); the RetryPolicy.builder() form was compiled and run on Spring Framework 7.0.9.
What the run showed, and where it differs from the Resilience4j implementation:
| Call | Observed |
|---|---|
a supplier that always throws |
invoked 3 times (1 + 2 retries); the fallback received |
the next call while open |
the supplier was not invoked; the fallback received the last exception ( |
a call after 700 ms |
half-open, the call ran, succeeded, state |
|
|
Source: run for this page (FrameworkRetryTest) on SCCB 5.0.3 / Spring Framework 7.0.9; source in
FrameworkRetryCircuitBreaker at v5.0.3.
Five things to know before adopting it:
-
resetTimeoutcan close the circuit beforeopenTimeoutelapses.canRetry()resets anOPENstate toCLOSEDas soon asresetTimeouthas passed since the last failure. With the defaults (open 20 s, reset 5 s) the circuit therefore stays open for about 5 seconds, not 20. A test with open 2 s and reset 300 ms found the circuit closed and calls flowing 500 ms after the failure. KeepresetTimeoutat least as long asopenTimeoutif you want the open window you configured. -
One failure opens it. There is no sliding window, no failure-rate threshold, no slow-call rate: one exhausted invocation opens the circuit. It is a coarse "stop hammering for a while" mechanism, not a replacement for the Resilience4j breaker.
-
No time limiter, bulkhead, metrics or events. The implementation is a
RetryTemplateplus a small state machine with noExecutorService, so the supplier runs on the calling thread and nothing limits its duration. -
State is per id, per factory. Each id gets its own
CircuitBreakerRetryPolicy;configureDefaultis applied lazily per id. -
The reference page is stale on
RetryPolicy. It listsRetryPolicy.withMaxDuration,withBackoff,forExceptionsandand()/or()combinators; the Spring Framework 7.0.9 sources havewithDefaults(),withMaxRetries(long)andbuilder()(withmaxRetries,delay,jitter,multiplier,maxDelay,timeout,includes,excludes), and none of those combinators.
Source: CircuitBreakerRetryPolicy at v5.0.3 and Spring Framework 7.0.9 RetryPolicy sources.
For the Resilience4j library Retry, which is a different thing with backoff, jitter and metrics, see
Retry.
Abstraction or Resilience4j directly
SCCB buys portability and a single call shape; Resilience4j buys everything else.
| Need | SCCB abstraction | Resilience4j directly |
|---|---|---|
One-line protection of a call, fallback in the same expression |
|
|
Retry, rate limiter, cache, hedge, timer |
Not available (the Framework Retry implementation retries; the Resilience4j one does not) |
Full set, composable with |
Per-call events, health indicators, Actuator endpoints for breakers |
The auto-configured registry gives you the Resilience4j endpoints if the starter is present; no per-call API |
Registries, event consumers, health indicator, Spring Boot Configuration |
Library independence (swap implementation without touching callers) |
Yes: the callers see |
Callers import |
Components that already use SCCB: Spring Cloud Gateway filter, OpenFeign, the HTTP service integration |
Required: those integrations call |
Not applicable |
Control over threads and exception types |
Hidden: executor hop, thread-pool bulkhead, |
Yours: no extra thread unless you add a |
Reactive pipelines |
|
Reactor operators for all modules, see Reactive, Kotlin & RxJava |
Source: Spring Cloud Commons and the sections of this page; the Gateway and OpenFeign integrations are in Gateway & OpenFeign.
A workable rule: use the abstraction in code that only needs "run this, or this if it fails" and in libraries that want
to stay vendor-neutral; use Resilience4j directly (the annotations or the registries) where you need retry, rate limiting,
events or tight control of threads and exceptions. Both can coexist in one application — the SCCB factory and the
@CircuitBreaker aspect use the same CircuitBreakerRegistry, which is why SCCB metrics and the Actuator breaker
endpoints show both — but an id used through both is one breaker, so keep their names distinct unless sharing is the point.
The Spring Batch counterpart, a PricingItemProcessor that wraps its remote call with factory.create("pricing") and
degrades to the last known price, is in
Spring Batch: Guarding remote calls; everything on this
page about thread hops, the one-second TimeLimiter and ExecutionException applies to it unchanged. Spring Batch’s own
item-level skip and retry, which that processor combines with the breaker, is in
Spring Batch: fault tolerance, skip and retry.
Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
Fallback assumes the real exception type |
With |
Unwrap, or use the semaphore bulkhead, or exclude the module, or disable the bulkhead wrapper. |
A transitive dependency adds |
The type of every fallback |
Pin the choice explicitly (the property or the exclusion) and cover it with a test, see Testing Resilience. |
The 1 s default time limit |
A slow-but-fine call fails with a timeout after 1 s, and the supplier thread is interrupted |
Set |
|
The |
Keep the pool and tune it; or accept no time limit and set client timeouts. |
YAML and a customizer for the same id |
The YAML instance (or |
One source per id. |
Customizer registering listeners in |
One more listener on every call |
Wrap in |
|
The bulkhead is created as a semaphore bulkhead in the registry and selected over the thread-pool default |
Know that it changes the implementation; set the choice explicitly. |
|
The supplier runs on a pool thread: MDC, security context, transaction are missing |
A context-propagating executor through |
|
Has no effect; the filter’s condition reads the camelCase name |
|
Reactive flag spelled in kebab-case |
The |
|
|
"Module … is only compatible with Spring Boot 3.x": application fails to start |
Exclude it from the SCCB starter and add |
Version override with only the |
The imported |
Import |
Framework Retry |
The circuit closes after the reset timeout; defaults give about 5 s open, not 20 s |
Set both explicitly with |
Fallback class for |
|
Public class, public constructor, public methods. |
Source: the experiments on this page; see also Production Tuning & Anti-patterns for the general catalogue.
What changed recently
-
Spring Cloud CircuitBreaker 5.0 (release train 2025.1) targets Spring Boot 4 and Spring Framework 7. New: the Framework Retry implementation,
@HttpServiceFallbackand the HTTP service group configurers, observation support (spring.cloud.circuitbreakertimer and long task timer),disable-thread-pool,disable-time-limiter-map, group executors (configureGroupExecutorService), and the reactive bulkhead provider. -
Spring Cloud CircuitBreaker 5.1.0-M1 (milestone, not for production) moves its parent to Resilience4j 2.4.0 and declares
resilience4j-spring-boot4; once it is released, the alignment recipe above becomes unnecessary. -
Resilience4j 2.4.0 adds
resilience4j-spring-boot4, makesresilience4j-micrometerbringresilience4j-bulkheadtransitively (which is what exposes theExecutionExceptionbehaviour for SCCB users), and adds the startup verifier that rejects the wrong Boot starter. The release notes are only on the GitHub release page. -
Hystrix and the Netflix-era starters are gone from Spring Cloud; see Migration & What’s Changed.
References
-
Spring Cloud CircuitBreaker: Resilience4j and its sub-pages: starters, default configuration, specific circuit breaker configuration, circuit breaker properties, bulkhead pattern, specific bulkhead configuration, bulkhead properties and collecting metrics
-
Spring Cloud CircuitBreaker: Framework Retry and Spring Retry implementations
-
Spring Cloud Commons: Spring Cloud Circuit Breaker (the abstraction)
-
Spring Cloud CircuitBreaker v5.0.3 source and Spring Cloud supported versions
-
Resilience4j: Spring Boot 2 & 3, CircuitBreaker, Bulkhead and TimeLimiter
-
Resilience4j v2.4.0 release and issue 2427,
resilience4j-spring-boot4missing from the BOM -
Spring Framework: Resilience features and REST clients and HTTP service groups