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 ExecutionException instead of the real exception as soon as resilience4j-bulkhead is on the classpath, which is the case after aligning to Resilience4j 2.4.0 (the ExecutionException finding);

  • YAML configs.<name> entries beat factory.configure(…​) customizers, and a YAML instances.<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.enableGroupMeterFilter is 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.

Table 1. The Spring Cloud CircuitBreaker API in Spring Cloud Commons 5.0.3
Type What it does Notes

CircuitBreakerFactory<CONF, CONFB>

create(String id) and create(String id, String groupName) return a CircuitBreaker. configureDefault(…​) and configure(consumer, ids…​) come from the shared base class AbstractCircuitBreakerFactory.

The two-argument create defaults to create(id) in the base class; only implementations that understand groups override it.

CircuitBreaker

run(Supplier<T> toRun, Function<Throwable, T> fallback) and the shortcut run(Supplier<T> toRun).

The shortcut installs a fallback that throws NoFallbackAvailableException("No fallback available.", throwable).

ReactiveCircuitBreakerFactory and ReactiveCircuitBreaker

run(Mono<T>), run(Mono<T>, Function<Throwable, Mono<T>>) and the same pair for Flux.

Needs reactor-core and, for Resilience4j, resilience4j-reactor.

Customizer<T>

A functional interface with customize(T) and the static helper once(customizer, keyMapper).

The unit of configuration: a Customizer<Resilience4JCircuitBreakerFactory> bean is applied to the factory when it is created.

ConfigBuilder<CONF>

Builds the implementation-specific configuration (Resilience4JConfigBuilder, FrameworkRetryConfigBuilder).

Passed to the configure(consumer, ids…​) lambda.

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:

A Bookshelf service written against the abstraction (PricingService.java)
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:

Table 2. Output of the Bookshelf example against a stub PricingClient
Stub behaviour quote("9780132350884")

returns 12.50

Quote[amount=12.50, source=pricing-service]

throws IllegalStateException

Quote[amount=19.99, source=list price (error)]

sleeps 1.2 s (limit 800 ms)

Quote[amount=19.99, source=list price (timeout)]

enough further failures to open the breaker, then any call

Quote[amount=19.99, source=list price (circuit open)], the supplier is not invoked

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:

Table 3. What the id and the group name in the Resilience4j implementation
Resilience4j object Looked up by Notes

CircuitBreaker

the id

One state machine per id, tagged group=<groupName>.

TimeLimiter

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.

Bulkhead or ThreadPoolBulkhead

the group

One bulkhead per group, shared by all ids of the group.

ExecutorService that runs the supplier

the group (create(id, group)) or the factory-wide executor (create(id))

configureGroupExecutorService(Function<String, ExecutorService>) configures the group variant; the default creates a cached thread pool per group.

Micrometer tag group

the group

See Metrics and observation.

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:

Table 4. Spring Cloud CircuitBreaker 5.0.3 starters
Artifact (group org.springframework.cloud) Provides What it pulls

spring-cloud-starter-circuitbreaker-resilience4j

CircuitBreakerFactory backed by Resilience4JCircuitBreakerFactory

spring-cloud-circuitbreaker-resilience4j, resilience4j-circuitbreaker, resilience4j-timelimiter (all 2.3.0 unless aligned). resilience4j-bulkhead is optional.

spring-cloud-starter-circuitbreaker-reactor-resilience4j

ReactiveCircuitBreakerFactory backed by ReactiveResilience4JCircuitBreakerFactory

The above plus resilience4j-reactor.

spring-cloud-starter-circuitbreaker-framework-retry

CircuitBreakerFactory backed by FrameworkRetryCircuitBreakerFactory, built on Spring Framework 7 RetryTemplate

spring-cloud-circuitbreaker-framework-retry and nothing from Resilience4j.

spring-cloud-starter-circuitbreaker-spring-retry

CircuitBreakerFactory backed by the archived Spring Retry

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.

Table 5. What the SCCB 5.0.3 starter brings by default on Boot 4.1.1
Artifact Version

spring-cloud-circuitbreaker-resilience4j

5.0.3

resilience4j-spring-boot3

2.3.0 (a Boot 3 starter on a Boot 4 application)

resilience4j-spring6, resilience4j-micrometer, resilience4j-circuitbreaker, resilience4j-timelimiter

2.3.0

resilience4j-bulkhead

not present (optional, and resilience4j-micrometer 2.3.0 does not pull it)

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:

Maven alignment: BOM first, exclude the Boot 3 starter, add the Boot 4 starter (pom.xml)
<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.version property alone does not work: the imported spring-cloud-dependencies BOM still resolves 2.3.0 for the Resilience4j artifacts and the tree ends up mixed. Importing resilience4j-bom first makes its versions win.

  • resilience4j-spring-boot4 is not in the BOM (also true of resilience4j-hedge), so it carries an explicit version.

  • resilience4j-spring-boot3 must be excluded. Resilience4j 2.4.0 ships a startup verifier in both starters, and resilience4j-spring-boot3 2.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):

Gradle Kotlin DSL alignment (build.gradle.kts, not run)
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")
}

What alignment changes

Two consequences, one visible and one easy to miss:

  • A WARN at startup. Resilience4JAutoConfiguration and Resilence4JMetricsOrderingAutoConfiguration declare their ordering against io.github.resilience4j.springboot3…​ classes, which do not exist in resilience4j-spring-boot4 (the package there is io.github.resilience4j.springboot…​). Spring logs Failed to introspect meta-annotation @AutoConfigureAfter …​ TypeNotPresentException and 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-bulkhead arrives. resilience4j-micrometer 2.4.0 depends on resilience4j-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.

Where the exception is wrapped, from the 5.0.3 source
// 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:

Table 6. Experiment: the same failing supplier on four classpaths and one switch
Classpath Bulkhead provider bean What the fallback receives

aligned: Resilience4j 2.4.0 (resilience4j-micrometer brings resilience4j-bulkhead)

yes

ExecutionException caused by IllegalStateException

unaligned: SCCB-pinned 2.3.0, no resilience4j-bulkhead

no

IllegalStateException

unaligned plus resilience4j-bulkhead 2.3.0

yes

ExecutionException caused by IllegalStateException

aligned, with resilience4j-bulkhead excluded

no

IllegalStateException

aligned with spring.cloud.circuitbreaker.bulkhead.resilience4j.enabled=false

no

IllegalStateException

aligned with spring.cloud.circuitbreaker.resilience4j.enable-semaphore-default-bulkhead=true

yes (semaphore)

IllegalStateException

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 Throwable is wrapped: a TimeoutException from the time limiter arrives as ExecutionException > TimeoutException; IllegalStateException as ExecutionException > IllegalStateException. The same is true of the Throwable passed to an @HttpServiceFallback method that takes one.

  • The circuit breaker is not fooled: Resilience4j’s state machine unwraps ExecutionException and CompletionException before applying the record and ignore predicates. With ignoreExceptions: java.lang.IllegalArgumentException the exception counted as ignored (zero failed, zero successful) on the thread-pool bulkhead and on the semaphore bulkhead alike.

  • CallNotPermittedException and BulkheadFullException are not wrapped: the breaker is the outermost layer, and the semaphore bulkhead and a full thread-pool bulkhead throw before any future exists.

  • NoFallbackAvailableException from run(supplier) wraps whatever the fallback would have received, so its cause is the ExecutionException and 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).

Nested boxes from outside in: the CircuitBreaker named by the id, a Bulkhead named by the group, and a TimeLimiter that looks up the id, then the group, then a one second default. Inside the time limiter an executorService, a cached pool per group, submits the supplier to a pool thread. A red arrow returns from the outer box to the caller: the circuit breaker records the throwable and then the fallback receives it on the caller thread. A red box explains that the time limiter rethrows the original cause or a TimeoutException but the thread-pool bulkhead calls CompletableFuture.get and wraps it in an ExecutionException. A strip below notes that without resilience4j-bulkhead the bulkhead layer disappears and that disable-thread-pool drops the executor and the time limiter

The branches, from Resilience4JCircuitBreaker.run and the factory:

Table 7. The four decoration shapes
Situation Decoration (outermost first)

Default: executor, TimeLimiter, bulkhead provider present

CircuitBreaker > bulkhead (by group) > TimeLimiter > executor.submit(supplier)

No resilience4j-bulkhead (or …​bulkhead.resilience4j.enabled=false)

CircuitBreaker > TimeLimiter > executor.submit(supplier)

disable-time-limiter for this id or group

the same, with futureSupplier.get().get() instead of the TimeLimiter: no timeout at all

spring.cloud.circuitbreaker.resilience4j.disable-thread-pool=true

CircuitBreaker > bulkhead (if present) > supplier on the calling thread (on a bulkhead thread when the bulkhead is a thread pool); no TimeLimiter is applied

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:

Table 8. Run: a supplier that sleeps 1.5 s under different settings
Setting Result Time limiter in the registry

defaults

fallback after about 1 s, TimeoutException (wrapped, see above)

PT1S for the id

resilience4j.timelimiter.configs.default.timeout-duration=3s

completes after 1.5 s

PT3S

resilience4j.timelimiter.instances.tight.timeout-duration=300ms, id tight

fallback after about 0.3 s

PT0.3S

spring.cloud.circuitbreaker.resilience4j.disable-time-limiter=true

completes after 1.5 s

none created

…​disable-time-limiter-map.relaxed=true, id relaxed

completes; another id (other) still times out at 1 s

none for relaxed

…​disable-time-limiter-map.pricing-service=true, create("byGroup", "pricing-service")

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):

Table 9. Spring Cloud CircuitBreaker Resilience4j properties (5.0.3)
Property Default Effect

disable-time-limiter

false

Turns the TimeLimiter off for every breaker.

disable-time-limiter-map

empty

Map<String, Boolean> keyed by id or group; the id wins over the group, the group over the global flag. instanceA: false re-enables one id under a global true.

disable-thread-pool

false

No ExecutorService: the supplier runs on the calling thread and no TimeLimiter is applied, whatever disable-time-limiter says.

enable-semaphore-default-bulkhead

false

Use a semaphore instead of a thread-pool bulkhead when nothing says otherwise. See below.

enableGroupMeterFilter

true

Adds the group tag to resilience4j.* meters that lack it. Read in camelCase only.

default-group-tag

none

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>.

Table 10. Resilience4JCircuitBreakerFactory configuration methods
Method What it does

configureDefault(Function<String, Resilience4JCircuitBreakerConfiguration>)

The function applied to every id that has no configure(…​) entry. The built-in default builds from the registries' default configurations, i.e. from resilience4j.circuitbreaker.configs.default and resilience4j.timelimiter.configs.default in YAML; replacing it makes those YAML defaults irrelevant for ids that reach it.

configure(Consumer<Resilience4JConfigBuilder>, String…​ ids)

One configuration (circuit breaker plus time limiter) for each listed id.

addCircuitBreakerCustomizer(Customizer<CircuitBreaker>, String…​ ids)

A hook that receives the Resilience4j CircuitBreaker — to register event consumers, for instance. It runs on every run, not once.

Customizer.once(customizer, keyMapper)

Wraps a customizer so it runs once per key, typically CircuitBreaker::getName.

configureExecutorService(ExecutorService)

The executor for create(id). Default: Executors.newCachedThreadPool().

configureGroupExecutorService(Function<String, ExecutorService>)

The executor factory for create(id, group); default: a cached thread pool per group.

configureCircuitBreakerRegistry(CircuitBreakerRegistry)

Replace the registry (Boot already supplies the auto-configured one).

The Bookshelf customizers, in a profile so they only apply where wanted:

Customizers for the Bookshelf pricing breaker (BookshelfCircuitBreakerConfig.java)
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");
    }
}

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:

Replacing the executor that runs suppliers
@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:

Table 11. Which configuration wins for the circuit breaker (observed sliding window size)
Sources present for the id Winner Window

YAML instances.<id>, plus a customizer configure(…​, id)

the YAML instance

66 (YAML), customizer 22 ignored

YAML configs.<id>, plus a customizer configure(…​, id)

the YAML config

55 (YAML), customizer 22 ignored

YAML configs.<group>, nothing for the id

the YAML group config

88

YAML instances.<id> with baseConfig: shared

the instance, built from configs.shared

44

only a customizer configure(…​, id)

the customizer

22

nothing for the id; a customizer configureDefault

the default function

11 (the YAML configs.default with 33 was not used)

nothing for the id, no configureDefault

YAML configs.default through the registry

33

YAML instances.<group> only, create(id, group)

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.

flowchart TD A["create(id, group)"] --> B{"YAML instances.id exists?"} B -- yes --> W1["that instance: customizers for the id are dead code"] B -- no --> C{"YAML configs.id exists?"} C -- yes --> W2["configs.id"] C -- no --> D{"YAML configs.group exists?"} D -- yes --> W3["configs.group"] D -- no --> E{"factory.configure for the id?"} E -- yes --> W4["the customizer configuration"] E -- no --> F["configureDefault function; built in, it reads YAML configs.default"]

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):

application-bookshelf.yml
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):

Table 12. Which bulkhead SCCB uses for a group
Order Rule

1

A ThreadPoolBulkhead of that name already exists in the registry (YAML resilience4j.thread-pool-bulkhead.instances.<group>, or addThreadPoolBulkheadCustomizer): thread pool, whatever the flag says.

2

Otherwise, spring.cloud.circuitbreaker.resilience4j.enable-semaphore-default-bulkhead=true: semaphore.

3

Otherwise, a semaphore Bulkhead of that name already exists (YAML resilience4j.bulkhead.instances.<group>, or addBulkheadCustomizer): semaphore.

4

Otherwise: thread pool, built from the registry’s default ThreadPoolBulkheadConfig.

The run confirmed the table:

Table 13. Run: which registry got the group’s bulkhead
Setting Result for group pricing-service

nothing

ThreadPoolBulkhead, core 7, max 8, queue 100 on an 8-CPU machine (core = CPUs minus one, max = CPUs)

enable-semaphore-default-bulkhead=true

semaphore, maxConcurrentCalls 25

YAML bulkhead.instances.pricing-service.max-concurrent-calls=1

semaphore; a second concurrent call got BulkheadFullException and went to the fallback unwrapped

YAML thread-pool-bulkhead.instances.pricing-service…​ plus the semaphore flag

thread pool (rule 1 beats the flag)

provider.configure(…​, "pricing-service") with a semaphore and a thread-pool config

thread pool (the config alone does not select an implementation)

provider.configure(…​) plus the semaphore flag

semaphore with the configured maxConcurrentCalls 1

provider.addBulkheadCustomizer(…​, "pricing-service") and nothing else

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:

Table 14. Meters from one run (names as Resilience4j registers them; the SCCB addition is the group tag)
Meter Tags

resilience4j.circuitbreaker.state

group=pricing-service, name=pricing, state with the values closed, open, half_open, disabled, forced_open and metrics_only

resilience4j.circuitbreaker.calls, .buffered.calls, .slow.calls

group, name, kind

resilience4j.circuitbreaker.failure.rate, .slow.call.rate, .not.permitted.calls

group, name (and kind=not_permitted)

resilience4j.timelimiter.calls

group, name=pricing, kind with the values successful, failed and timeout

resilience4j.bulkhead.thread (thread-pool bulkhead gauges: active.thread.count, available.thread.count, core.thread.pool.size, max.thread.pool.size, queue.capacity, queue.depth, thread.pool.size)

group=pricing-service, name=pricing-service (named after the group)

spring.cloud.circuitbreaker (timer) and spring.cloud.circuitbreaker.active (long task timer)

spring.cloud.circuitbreaker.type=supplier, plus error

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> (default none) to any resilience4j.* meter that has no group tag — the meters of breakers created through the registry directly rather than through the factory. A direct registry.circuitBreaker("direct") showed group=none, and default-group-tag=ungrouped changed that to ungrouped.

  • The camelCase pitfall. The filter bean is guarded by @ConditionalOnProperty on the camelCase name spring.cloud.circuitbreaker.resilience4j.enableGroupMeterFilter. Setting the kebab-case enable-group-meter-filter=false did not disable it (the direct breaker was still tagged group=none); only enableGroupMeterFilter: false removed the tag. The other switches are single-word, so they are unaffected.

  • Observation. When an ObservationRegistry bean exists (Actuator provides one), create(…​) returns an ObservedCircuitBreaker that wraps the supplier and the fallback in a spring.cloud.circuitbreaker observation with the low-cardinality key spring.cloud.circuitbreaker.type (supplier or function). Turn it off with spring.cloud.circuitbreaker.resilience4j.micrometer.enabled=false.

  • Tagged metrics bean. The auto-configuration only registers its own TaggedCircuitBreakerMetrics when no TaggedCircuitBreakerMetricsPublisher bean 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:

The reactive factory
@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:

Table 15. @HttpServiceFallback semantics (5.0.3)
Aspect Behaviour

The breaker

One per HTTP service group: circuitBreakerFactory.create(groupName). Configure it as the id <group> (YAML resilience4j.circuitbreaker.instances.pricing).

Where the annotation goes

On a Spring bean class (context.getBeansWithAnnotation). value names the class that holds the fallback methods; it may be the annotated class itself. Repeatable.

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 Throwable first, then the same parameters. If neither exists: NoFallbackAvailableException("No fallback available.", throwable).

service()

The HTTP interfaces the fallback applies to; omitted means a default for all services in the group. A per-service entry beats the default.

group()

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 IllegalStateException("Duplicate fallback key: …​").

The Throwable

Whatever the circuit breaker’s fallback receives: ExecutionException when resilience4j-bulkhead is on the classpath (see above).

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:

An HTTP interface and its fallback (PricingApi.java, PricingApiFallback.java, HttpClientsConfig.java)
@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.

Table 16. FrameworkRetryConfigBuilder options and defaults
Option Default Meaning

retryPolicy(RetryPolicy)

RetryPolicy.withDefaults(): 3 retries (4 invocations), 1 s delay

Used while the circuit is closed or half-open.

openTimeout(Duration)

20 s

How long the circuit stays open before one test call is allowed (half-open).

resetTimeout(Duration)

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).

A Framework Retry breaker for pricing (FrameworkRetryTest.java)
@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:

Table 17. Framework Retry run (maxRetries 2, openTimeout 600 ms, resetTimeout 30 s)
Call Observed

a supplier that always throws

invoked 3 times (1 + 2 retries); the fallback received RetryException caused by IllegalStateException; the state became OPEN

the next call while open

the supplier was not invoked; the fallback received the last exception (RetryException); there is no CallNotPermittedException

a call after 700 ms

half-open, the call ran, succeeded, state CLOSED

run(supplier) without a fallback

NoFallbackAvailableException caused by RetryException caused by the real exception

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:

  • resetTimeout can close the circuit before openTimeout elapses. canRetry() resets an OPEN state to CLOSED as soon as resetTimeout has 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. Keep resetTimeout at least as long as openTimeout if 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 RetryTemplate plus a small state machine with no ExecutorService, 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; configureDefault is applied lazily per id.

  • The reference page is stale on RetryPolicy. It lists RetryPolicy.withMaxDuration, withBackoff, forExceptions and and()/or() combinators; the Spring Framework 7.0.9 sources have withDefaults(), withMaxRetries(long) and builder() (with maxRetries, 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.

Table 18. When to use which
Need SCCB abstraction Resilience4j directly

One-line protection of a call, fallback in the same expression

breaker.run(supplier, fallback)

@CircuitBreaker, Decorators, executeSupplier

Retry, rate limiter, cache, hedge, timer

Not available (the Framework Retry implementation retries; the Resilience4j one does not)

Full set, composable with Decorators and annotations, see Fallbacks & Composition

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 CircuitBreaker

Callers import io.github.resilience4j.*

Components that already use SCCB: Spring Cloud Gateway filter, OpenFeign, the HTTP service integration

Required: those integrations call CircuitBreakerFactory

Not applicable

Control over threads and exception types

Hidden: executor hop, thread-pool bulkhead, ExecutionException in the fallback

Yours: no extra thread unless you add a ThreadPoolBulkhead; exceptions arrive as thrown

Reactive pipelines

ReactiveCircuitBreakerFactory: breaker, semaphore bulkhead, timeout

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

Table 19. Spring Cloud CircuitBreaker pitfalls
Pitfall What happens Fix

Fallback assumes the real exception type

With resilience4j-bulkhead on the classpath the fallback gets ExecutionException; instanceof TimeoutException is false

Unwrap, or use the semaphore bulkhead, or exclude the module, or disable the bulkhead wrapper.

A transitive dependency adds resilience4j-bulkhead

The type of every fallback Throwable changes without a compile error

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 resilience4j.timelimiter.configs.default.timeout-duration or an instance; use disable-time-limiter-map for streaming calls.

disable-thread-pool=true to "remove the extra thread"

The TimeLimiter is not applied at all, regardless of its settings

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 configs.<id>) wins silently; the customizer is dead code

One source per id.

Customizer registering listeners in addCircuitBreakerCustomizer

One more listener on every call

Wrap in Customizer.once(…​, CircuitBreaker::getName).

addBulkheadCustomizer "just to add a listener"

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.

ThreadLocal context in the supplier

The supplier runs on a pool thread: MDC, security context, transaction are missing

A context-propagating executor through configureExecutorService, or capture values before run.

enable-group-meter-filter: false in YAML

Has no effect; the filter’s condition reads the camelCase name

enableGroupMeterFilter: false.

Reactive flag spelled in kebab-case

The @Value placeholder of the reactive bulkhead provider does not see it

enableSemaphoreDefaultBulkhead (and know the reactive provider is always a semaphore).

resilience4j-spring-boot3 2.4.0 on Boot 4

"Module …​ is only compatible with Spring Boot 3.x": application fails to start

Exclude it from the SCCB starter and add resilience4j-spring-boot4, as in the recipe above.

Version override with only the resilience4j.version property

The imported spring-cloud-dependencies BOM still resolves 2.3.0: a mixed 2.3.0 / 2.4.0 tree

Import resilience4j-bom before spring-cloud-dependencies.

Framework Retry resetTimeout shorter than openTimeout

The circuit closes after the reset timeout; defaults give about 5 s open, not 20 s

Set both explicitly with resetTimeout at least equal to openTimeout.

Fallback class for @HttpServiceFallback without a public no-argument constructor

FallbackProxyCreationException when the first fallback is needed

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, @HttpServiceFallback and the HTTP service group configurers, observation support (spring.cloud.circuitbreaker timer 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, makes resilience4j-micrometer bring resilience4j-bulkhead transitively (which is what exposes the ExecutionException behaviour 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.