Reactive, Kotlin & RxJava

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

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

A blocking call is protected by wrapping a method; a non-blocking call is protected by wrapping a pipeline. The call has not happened yet when the decorator is applied, it may be subscribed to more than once, and it may be cancelled before it finishes, so the decorators have to hook into subscription, signals and cancellation instead of a method call. This page covers how Resilience4j does that for Project Reactor (Mono and Flux), for RxJava 2 and 3, and for Kotlin coroutines (suspend functions and Flow), what the Spring annotations do with those return types, how the order of operators maps to the order of decorators, and when plain blocking code on virtual threads is the simpler answer. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21, and every example on it was compiled and run on those versions on 4 October 2026. The operators, the extension functions and the aspect extensions are identical on Spring Boot 3.5.x (verified on 3.5.16): the starter is resilience4j-spring-boot3 with spring-boot-starter-aop, and the libraries that Boot manages differ in version, as the table below shows. The vocabulary is in Resilience Fundamentals, the registries and decorators in Registries, Configuration & Decorators, and the programming model of Mono and Flux in Reactive Programming.

In the running Bookshelf scenario, bookshelf-service streams the reviews of a book from reviews-service through a WebClient. Reviews are nice to have: when the service is slow, flaky or down, the book page must still render, so the client is retried, bounded in time, limited in concurrency and wrapped in a circuit breaker, and the last line of defence is an empty list.

Which module for which style

Each style has its own artifact. They contain no Spring code, so they work in any application; the Spring annotations are handled by aspect extensions that live in resilience4j-spring6 and switch on when the matching module is on the classpath.

Style Artifact Entry points Spring annotations

Project Reactor

resilience4j-reactor

CircuitBreakerOperator, RetryOperator, RateLimiterOperator, BulkheadOperator, TimeLimiterOperator and TimerOperator, each a UnaryOperator<Publisher<T>> for transformDeferred

Yes, on methods that return Mono or Flux

RxJava 3

resilience4j-rxjava3

CircuitBreakerOperator, RateLimiterOperator, BulkheadOperator, RetryTransformer, TimeLimiterTransformer, TimerTransformer, for compose

Yes, on Flowable, Observable, Single, Maybe and Completable

RxJava 2

resilience4j-rxjava2

The same classes in packages without the rxjava2 segment

Yes, same types

Kotlin

resilience4j-kotlin

executeSuspendFunction, decorateSuspendFunction and Flow operators for the five patterns, plus DSLs for configs and registries

No

Blocking, on virtual threads

the core modules and the starter

Annotations, Decorators, executeSupplier

Yes

All four reactive and Kotlin artifacts are in the Resilience4j BOM (the BOM lists resilience4j-reactor, -rxjava2, -rxjava3 and -kotlin), so with the BOM imported they need no version. The starter itself is not in the BOM at 2.4.0 and keeps its explicit version, as on Getting Started. The 2.4.0 artifacts were built against old libraries: the POM of resilience4j-reactor declares reactor-core 3.4.24, resilience4j-rxjava3 declares RxJava 3.0.0, resilience4j-rxjava2 declares RxJava 2.2.8, and resilience4j-kotlin declares Kotlin 1.9.25 and coroutines 1.6.4. In an application, Maven’s mediation and Boot’s dependency management pick the versions that the Boot line manages, and the examples below ran on them:

Library Spring Boot 4.1.1 Spring Boot 3.5.16

Reactor core

3.8.7

3.7.19

RxJava 3 (managed by Boot)

3.1.12

3.1.12

RxJava 2 (not managed, pin it)

2.2.21 (pinned)

2.2.21 (pinned)

Kotlin

2.3.21

1.9.25

kotlinx-coroutines

1.10.2

1.8.1

The dependencies for a Boot 4 service that uses all three styles (keep only the ones you need):

Dependencies for the reactive, RxJava and Kotlin styles
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.github.resilience4j</groupId>
      <artifactId>resilience4j-bom</artifactId>
      <version>${resilience4j.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <!-- the starter is not in the BOM at 2.4.0: pin it -->
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot4</artifactId>
    <version>${resilience4j.version}</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aspectj</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>

  <!-- Reactor and WebClient (Boot 4 split the WebClient auto-configuration into its own starter) -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webclient</artifactId>
  </dependency>
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-reactor</artifactId>
  </dependency>

  <!-- RxJava 3 (RxJava 2 needs an explicit io.reactivex.rxjava2:rxjava version, Boot does not manage it) -->
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-rxjava3</artifactId>
  </dependency>
  <dependency>
    <groupId>io.reactivex.rxjava3</groupId>
    <artifactId>rxjava</artifactId>
  </dependency>

  <!-- Kotlin: kotlin-maven-plugin setup is on the Concurrency Alternatives page -->
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-kotlin</artifactId>
  </dependency>
  <dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib</artifactId>
  </dependency>
  <dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-reactor</artifactId>
  </dependency>
</dependencies>

Source: Resilience4j: resilience4j-reactor, resilience4j-rxjava2/3, resilience4j-kotlin and the Resilience4j BOM at v2.4.0. Resolved with mvn dependency:tree on Boot 4.1.1 and 3.5.16. On Boot 3 replace the starter with resilience4j-spring-boot3 and spring-boot-starter-aspectj with spring-boot-starter-aop, and drop spring-boot-starter-webclient, which does not exist there. The Maven setup that compiles Kotlin next to Java is in Concurrency Alternatives.

The readme.io pages for these modules say the reactive library is "not a transitive dependency", and show compile configurations resolved from jCenter(), which was shut down. In the 2.4.0 POMs reactor-core, rxjava (both majors) are compile-scope dependencies, and the Boot-managed version replaces the one in the POM. The Kotlin module lists the Kotlin standard library and coroutines at runtime scope.

The Bookshelf reviews client

The scenario is one WebClient call that returns a Flux<Review>. The configuration is the same for every style on this page, so it is shown once. It is a shared default plus one instance named reviews for each of the five patterns; a 404 for an unknown book is a normal answer, so it is ignored by the breaker and never retried.

The reviews instances in application.yml
resilience4j:
  retry:
    configs:
      default:
        maxAttempts: 3
        waitDuration: 20ms
        ignoreExceptions:
          - org.springframework.web.reactive.function.client.WebClientResponseException$NotFound
    instances:
      reviews:
        baseConfig: default
  circuitbreaker:
    configs:
      default:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 6
        minimumNumberOfCalls: 6
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
        ignoreExceptions:
          - org.springframework.web.reactive.function.client.WebClientResponseException$NotFound
    instances:
      reviews:
        baseConfig: default
  ratelimiter:
    instances:
      reviews:
        limitForPeriod: 50
        limitRefreshPeriod: 1s
        timeoutDuration: 0
  timelimiter:
    instances:
      reviews:
        timeoutDuration: 400ms
  bulkhead:
    instances:
      reviews:
        maxConcurrentCalls: 2
        maxWaitDuration: 0

Source: Resilience4j: Spring Boot 2 & 3 and the property classes in resilience4j-framework-common at v2.4.0. The YAML is the test configuration of the runs reported below. Every property is described on the page of its module, for example Retry and TimeLimiter. The time limiter’s 400ms and the bulkhead’s maxWaitDuration: 0 are deliberate and are explained in the sections on the operators.

Reactor operators

Operators decorate a subscription, not a call

Every operator in resilience4j-reactor is a UnaryOperator<Publisher<T>>: it takes a Mono or a Flux and returns a decorated Mono or Flux. The decoration happens at subscription time, which has three consequences that explain most of the behaviour on this page.

  • Building the pipeline does not acquire a permit, does not start a call and does not touch the breaker. In a run, a Mono decorated with a bulkhead operator and a circuit-breaker operator had executed its source zero times, held no permit and recorded no call after assembly; subscribing twice then executed the source twice and recorded two successful calls.

  • Every subscription is one call for the patterns. The circuit breaker, the bulkhead and the rate limiter ask for a permission when the subscriber arrives, before the source is subscribed, and a rejection arrives as an error signal (CallNotPermittedException, BulkheadFullException, RequestNotPermitted), not as an exception thrown at the call site.

  • Cancelling the subscription is part of the contract. A subscriber that goes away before the first item returns the permit without recording a success or a failure; one that goes away after an item counts as a success (from CircuitBreakerSubscriber and BulkheadSubscriber in the source).

sequenceDiagram participant S as Subscriber participant R as RetryOperator participant C as CircuitBreakerOperator participant W as WebClient source S->>R: subscribe R->>C: subscribe, attempt 1 C->>C: tryAcquirePermission C->>W: subscribe W-->>C: onError 503 C->>C: record failure C-->>R: onError R->>R: onError returns a delay, Mono.delay on the parallel scheduler R->>C: resubscribe, attempt 2 C->>C: tryAcquirePermission C->>W: subscribe W-->>C: onNext, onComplete C->>C: record success C-->>R: onNext, onComplete R-->>S: onNext, onComplete
Figure 1. A subscription that is retried through the breaker

The figure is the case "503, 503, 200" of the Bookshelf client. A retry that sits outside the breaker resubscribes through it, so the breaker sees every attempt.

The five operators

Operator What it does at subscription Behaviour that surprises people

CircuitBreakerOperator

Calls tryAcquirePermission(). If the breaker is open, the subscriber receives CallNotPermittedException as an error signal and the source is never subscribed. Otherwise it records the outcome.

On a Mono the result is recorded at the first item; on a Flux a success is recorded when the sequence completes (from MonoCircuitBreaker, FluxCircuitBreaker and CircuitBreakerSubscriber). A cancel before any item releases the permit and records nothing.

RetryOperator

Resubscribes to the source on error, through Reactor’s own retryWhen. The delay between attempts is a Mono.delay: no thread sleeps.

Attempts after the first run on Reactor’s parallel scheduler (observed: attempt 1 on main, attempts 2 and 3 on parallel-5 and parallel-6). A Flux that fails after two items replays from the start: a source that emits 1, 2 and then fails once produced [1, 2, 1, 2, 3]. A Java Error is never retried.

RateLimiterOperator

Calls reservePermission(permits). If a permit is free the source is subscribed at once; if the wait fits in timeoutDuration the subscription is delayed with Mono.delay; otherwise the subscriber receives RequestNotPermitted.

It never parks a thread. In a run with one permit per 400 ms, the second subscribe returned in 0 ms and the item arrived 405 ms later on parallel-8. With timeoutDuration zero it is rejected instead. RateLimiterOperator.of(limiter, permits) charges several permits: with four permits per period, a call costing three succeeded and the next was rejected. The permits attribute of @RateLimiter is not read by the Reactor aspect extension (see RateLimiter), so a weighted reactive call needs the operator.

BulkheadOperator

Calls tryAcquirePermission() and releases the permit on completion, error or cancel. A full bulkhead gives BulkheadFullException as an error signal.

With maxWaitDuration above zero the call to tryAcquirePermission() blocks the subscribing thread for up to that long. In a run, with the only permit taken, subscribe returned in 0 ms for maxWaitDuration zero and in 405 ms for 400 ms. On a Netty event loop that is a stall. Use zero.

TimeLimiterOperator

Applies Reactor’s timeout(Duration) with the configured timeoutDuration and reports the outcome to the time limiter.

The failure is Reactor’s TimeoutException; on a Flux the limit applies between signals, not to the whole sequence. See TimeLimiter: Mono and Flux.

TimerOperator

Records a Micrometer Timer around the subscription. It is in io.github.resilience4j.reactor.micrometer.operator, next to the other five.

It is the operator behind @Timer on Mono and Flux.

Source: Resilience4j: resilience4j-reactor examples and the CircuitBreakerOperator, RetryOperator, CorePublisherRateLimiterOperator, MonoBulkhead and TimeLimiterOperator sources at v2.4.0. The blocking maxWaitDuration comes from SemaphoreBulkhead.tryAcquirePermission in SemaphoreBulkhead. Every number in the table comes from a run on Boot 4.1.1, Reactor 3.8.7.

The operators accept only Mono and Flux. Handing apply any other Publisher fails with io.github.resilience4j.reactor.IllegalPublisherException and the message "Publisher of type <…​> is not supported by this operator". Through transformDeferred that cannot happen, because Reactor always passes a Mono or a Flux.

Decorating the reviews call with transformDeferred

The idiom is one transformDeferred per pattern, innermost first. The pipeline below is the Bookshelf client, and the last line is the one that matters: a fallback that turns any remaining error into an empty list.

The reviews client with operators
@Component
public class ReviewsOperators {

    private final WebClient http;
    private final Retry retry;
    private final CircuitBreaker circuitBreaker;
    private final RateLimiter rateLimiter;
    private final TimeLimiter timeLimiter;
    private final Bulkhead bulkhead;

    public ReviewsOperators(WebClient.Builder builder,
                            @Value("${bookshelf.reviews.url}") String baseUrl,
                            RetryRegistry retries,
                            CircuitBreakerRegistry breakers,
                            RateLimiterRegistry limiters,
                            TimeLimiterRegistry timeLimiters,
                            BulkheadRegistry bulkheads) {
        this.http = builder.baseUrl(baseUrl).build();
        this.retry = retries.retry("reviews");
        this.circuitBreaker = breakers.circuitBreaker("reviews");
        this.rateLimiter = limiters.rateLimiter("reviews");
        this.timeLimiter = timeLimiters.timeLimiter("reviews");
        this.bulkhead = bulkheads.bulkhead("reviews");
    }

    public Flux<Review> reviews(String isbn) {
        return http.get().uri("/reviews/{isbn}", isbn)
            .retrieve()
            .bodyToFlux(Review.class)
            .transformDeferred(BulkheadOperator.of(bulkhead))          // innermost
            .transformDeferred(TimeLimiterOperator.of(timeLimiter))
            .transformDeferred(RateLimiterOperator.of(rateLimiter))
            .transformDeferred(CircuitBreakerOperator.of(circuitBreaker))
            .transformDeferred(RetryOperator.of(retry))                // outermost
            .onErrorResume(t -> Flux.empty());
    }
}

Source: Resilience4j: resilience4j-reactor examples. The registries are the beans that the starter creates, described on Registries, Configuration & Decorators. The Review type is record Review(String isbn, String author, int stars).

The decorators do not have to be applied to every pipeline by hand: an ExchangeFilterFunction per downstream service applies them to every exchange of a WebClient, as shown on Programmatic Usage. The client was run against WireMock with four scenarios; the results are in the table in the section on the three styles. In the first, reviews-service answered 503, 503 and then 200: the call returned the two reviews after three HTTP calls, and the breaker had recorded two failures and one success.

transform is not enough: use transformDeferred

Mono.transform(operator) applies the operator when the pipeline is assembled; transformDeferred(operator) applies it again for each subscriber. For the circuit breaker, the bulkhead and the rate limiter the difference is invisible, because they act at subscription anyway. For RetryOperator it is not: the retry context, which counts the attempts, is created when the operator is applied.

One retry context shared, or one per subscriber
Mono<String> assembled = source.transform(RetryOperator.of(retry));              // context created now, shared by every subscriber
Mono<String> perSubscriber = source.transformDeferred(RetryOperator.of(retry)); // context created for each subscriber

Source: RetryOperator at v2.4.0 and Mono.transformDeferred in the Reactor reference. Run with a source that always fails and maxAttempts(3): the first subscriber to the transform form subscribed the source three times, the second subscriber only once, because the shared context had already spent its attempts; with transformDeferred both subscribers got three.

That is why every example, the readme.io ones and the aspects included, uses transformDeferred. The Spring aspects do the same for annotated methods (from ReactorRetryAspectExt and its siblings), which is the next section.

Annotations on Mono and Flux

The five annotations work on methods whose return type is Mono or Flux, once resilience4j-reactor is on the classpath. Nothing else changes in the setup of Getting Started: the aspects exist already, and the reactor aspect extensions (for example ReactorCircuitBreakerAspectExt) are registered when reactor.core.publisher.Flux and io.github.resilience4j.reactor.AbstractSubscriber are both on the classpath. The aspect lets the method run, takes the Mono or Flux it returned and applies the matching operator to it with transformDeferred.

The reviews client with annotations
@Component
public class ReviewsAnnotated {

    private final WebClient http;

    public ReviewsAnnotated(WebClient.Builder builder, @Value("${bookshelf.reviews.url}") String baseUrl) {
        this.http = builder.baseUrl(baseUrl).build();
    }

    @Retry(name = "reviews", fallbackMethod = "noReviews")
    @CircuitBreaker(name = "reviews")
    @RateLimiter(name = "reviews")
    @TimeLimiter(name = "reviews")
    @Bulkhead(name = "reviews")
    public Flux<Review> reviews(String isbn) {
        return http.get().uri("/reviews/{isbn}", isbn)
            .retrieve()
            .bodyToFlux(Review.class);
    }

    private Flux<Review> noReviews(String isbn, WebClientResponseException.NotFound e) {
        return Flux.empty();
    }

    private Flux<Review> noReviews(String isbn, Throwable t) {
        return Flux.empty();
    }
}

Source: Resilience4j: Spring Boot 2 & 3 and the ReactorCircuitBreakerAspectExt source at v2.4.0. This class and ReviewsOperators gave identical results in all four scenarios. The @TimeLimiter, @RateLimiter and @Bulkhead here use the instances of the same name (reviews), which is why the three share one name with the breaker and the retry in the YAML above.

Four details of this example decide whether it works.

  • The fallback is declared on the outermost annotation, @Retry. Each annotation runs its own fallback inside its own aspect. A fallbackMethod on @CircuitBreaker would answer before the @Retry ever saw a failure: with the fallback there, the first scenario made one HTTP call and returned an empty list instead of three calls and two reviews. The rule and its measurement are on Fallbacks & Composition.

  • The fallback must return the same kind of type, here Flux. The reactor fallback decorator wraps the returned Flux with onErrorResume (ReactorFallbackDecorator). A synchronous exception thrown by the method body, before it returns the Flux, is not routed to the fallback. Keep the body to assembling the pipeline.

  • The method must return a cold publisher. A retry resubscribes. WebClient pipelines are cold: each subscription is a new HTTP exchange. A Mono.just(result) built from a value that was already computed would replay the same value on every attempt.

  • The 404 overload is chosen first. Fallback resolution takes the most specific exception type, so WebClientResponseException.NotFound goes to the first method and everything else to the second. Because NotFound is in ignoreExceptions, neither the breaker nor the retry counts it.

Without resilience4j-reactor the annotations silently do nothing

If resilience4j-reactor is missing, the starter still starts, the aspects still run, and the annotated method still returns its Mono. But the aspect has no extension for Mono, so it treats the Mono as an ordinary return value: as far as the breaker is concerned, the call succeeded the moment the Mono was created. In a run without the module, a @CircuitBreaker method whose Mono always failed was called and subscribed eight times; the breaker’s last six calls were all recorded as successful, none as failed, and the state stayed CLOSED. There is no startup error and no log line at the default level (the condition logs at DEBUG that "Reactor related Aspect extensions are not activated").

The same applies to RxJava (the module resilience4j-rxjava2 or -rxjava3 and the library must both be present) and the fix is the same: add the module.

How operator order maps to decorator order

transformDeferred wraps what it receives, so the last call is the outermost decorator and the first one to see a subscriber. The order of the aspects is the same idea expressed as numbers: a lower order value is the outer aspect. The defaults make the annotations nest as Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(function))))) (see Annotations and aspect order), and the operator chain in ReviewsOperators above is written to give the same nesting.

flowchart TB SUB([Subscriber]) --> RT subgraph RT["RetryOperator -- the last transformDeferred, outermost"] subgraph CB["CircuitBreakerOperator"] subgraph RL["RateLimiterOperator"] subgraph TL["TimeLimiterOperator"] subgraph BH["BulkheadOperator -- the first transformDeferred, innermost"] SRC["WebClient source: one HTTP exchange per subscription"] end end end end end
Figure 2. Operator nesting of the reviews call

The order is a decision, not a detail. Two runs show what it changes.

Chain Observed Why

Retry outside CircuitBreaker

Source subscribed 3 times, breaker recorded 3 failures

Every attempt goes through the breaker. A burst of retries can open it, and an open breaker then stops the retries.

CircuitBreaker outside Retry

Source subscribed 3 times, breaker recorded 1 failure

The breaker sees one logical call that failed after the retries. It opens later, and the retries run even when the backend is clearly down.

CircuitBreaker outside TimeLimiter, source slower than the limit

Breaker buffered 1 call, 1 failed

The timeout is an error that travels outward through the breaker, which records it.

TimeLimiter outside CircuitBreaker, same source

Breaker buffered 0 calls

The timeout cancels the subscription that the breaker holds. The breaker releases the permit and records nothing: slow calls never count as failures.

Source: Resilience4j: Spring Boot 2 & 3 and the CircuitBreakerSubscriber source at v2.4.0. Runs on Reactor 3.8.7 with a source that fails with IllegalStateException and, for the last two rows, delays its item by 300 ms against a 50 ms limit.

The last row is the reason the default puts the time limiter inside the breaker: a timeout is exactly the failure a circuit breaker exists to count. Put the time limiter outside only when you want slow calls to be invisible to the breaker. Retrying outside the breaker and the order of the annotations are discussed in depth in Fallbacks & Composition.

RxJava 2 and 3

The RxJava modules follow the same model: the operators implement RxJava’s FlowableTransformer, SingleTransformer, MaybeTransformer, CompletableTransformer and ObservableTransformer, and you apply them with compose. The five patterns have operators for all five reactive types; the time limiter is a TimeLimiterTransformer and the retry a RetryTransformer, the other three are called …​Operator. Their packages differ between the two majors.

Pattern RxJava 3 package RxJava 2 package

CircuitBreaker

io.github.resilience4j.rxjava3.circuitbreaker.operator

io.github.resilience4j.circuitbreaker.operator

RateLimiter

io.github.resilience4j.rxjava3.ratelimiter.operator

io.github.resilience4j.ratelimiter.operator

Bulkhead

io.github.resilience4j.rxjava3.bulkhead.operator

io.github.resilience4j.bulkhead.operator

Retry

io.github.resilience4j.rxjava3.retry.transformer

io.github.resilience4j.retry.transformer

TimeLimiter

io.github.resilience4j.rxjava3.timelimiter.transformer

io.github.resilience4j.timelimiter.transformer

Source: Resilience4j: resilience4j-rxjava examples and the resilience4j-rxjava3 and resilience4j-rxjava2 sources at v2.4.0. The RxJava 2 package names have no rxjava2 segment, which is easy to miss; the RxJava 2 operators were run with CircuitBreakerOperator and RetryTransformer (same outcome as the RxJava 3 run below).

compose runs at assembly time

RxJava has no transformDeferred. compose applies the transformer once, when you call it, so RetryTransformer has the same trap as Reactor’s transform: the retry context is created at that moment and shared by every subscriber. In a run, the first subscriber to a composed Single subscribed the source three times and the second only once. The fix is defer: build the whole decorated chain inside it, so each subscriber gets a fresh chain.

The pricing call with RxJava 3
public Single<Money> price() {
    return Single.defer(() -> Single.fromCallable(remoteCall)
        .compose(BulkheadOperator.of(bulkhead))                  // innermost
        .compose(TimeLimiterTransformer.of(timeLimiter))
        .compose(RateLimiterOperator.of(rateLimiter))
        .compose(CircuitBreakerOperator.of(circuitBreaker))
        .compose(RetryTransformer.of(retry)));                   // outermost
}

Source: Resilience4j: resilience4j-rxjava examples and the RetryTransformer source at v2.4.0. The imports are the five classes in the io.github.resilience4j.rxjava3 packages of the table. Run with a call that failed twice and then answered: the price came back after three calls and the breaker recorded two failures; a second subscription ran the same three calls again. Without defer, the second subscription made one.

The order rule is the one from Reactor: the last compose is the outermost. The time limiter on a Flowable uses RxJava’s timeout; on a Flowable.interval with 300 ms between items and a 200 ms limit the failure was java.util.concurrent.TimeoutException with RxJava’s message "The source did not signal an event for 200 milliseconds and has been terminated", which blockingSubscribe wrapped in a RuntimeException.

Annotations on RxJava types

The aspect extensions for Observable, Single, Maybe, Completable and Flowable exist for both majors; they are the RxJava2…​AspectExt and RxJava3…​AspectExt classes of resilience4j-spring6, enabled by RxJava2OnClasspathCondition and RxJava3OnClasspathCondition. The aspect calls compose on the returned value, per call, so the defer problem does not arise there.

Annotations on a Single
@Component
public class RxBooks {

    private final Function<String, Single<String>> backend = isbn -> Single.just("19.99");

    @Retry(name = "rx")
    @CircuitBreaker(name = "rx")
    public Single<String> price(String isbn) {
        return Single.defer(() -> backend.apply(isbn));
    }
}

Source: Resilience4j: Spring Boot 2 & 3 and the RxJava3CircuitBreakerAspectExt source at v2.4.0. Run on Boot 4.1.1 with a source that failed twice: the call returned after three subscriptions with two failures and one success on the breaker. Support for RxJava 3 in the Spring aspects arrived in 2.3.0.

Kotlin: suspend functions and Flow

resilience4j-kotlin is a set of extension functions, not an integration: there are no aspects for it and nothing to configure in Spring. You inject the registries as usual and call the extensions.

For each of CircuitBreaker, Retry, RateLimiter, TimeLimiter and the semaphore Bulkhead there are two functions, executeSuspendFunction { } and decorateSuspendFunction { } (which returns a suspend lambda), and a Flow operator named after the pattern: circuitBreaker(…​), retry(…​), rateLimiter(…​), timeLimiter(…​) and bulkhead(…​). There is a Micrometer Timer extension as well. Blocking convenience wrappers (executeFunction, decorateFunction) exist for the same classes. The same client as before:

The reviews client with coroutines
@Component
class ReviewsCoroutines(
    builder: WebClient.Builder,
    @Value("\${bookshelf.reviews.url}") baseUrl: String,
    retries: RetryRegistry,
    breakers: CircuitBreakerRegistry,
    limiters: RateLimiterRegistry,
    timeLimiters: TimeLimiterRegistry,
    bulkheads: BulkheadRegistry,
) {
    private val http = builder.baseUrl(baseUrl).build()
    private val retry = retries.retry("reviews")
    private val circuitBreaker = breakers.circuitBreaker("reviews")
    private val rateLimiter = limiters.rateLimiter("reviews")
    private val timeLimiter = timeLimiters.timeLimiter("reviews")
    private val bulkhead = bulkheads.bulkhead("reviews")

    private fun fetch(isbn: String): Flow<Review> =
        http.get().uri("/reviews/{isbn}", isbn).retrieve().bodyToFlux(Review::class.java).asFlow()

    /** One call: each executeSuspendFunction wraps the next; the outermost block runs first. */
    suspend fun reviews(isbn: String): List<Review> =
        retry.executeSuspendFunction {
            circuitBreaker.executeSuspendFunction {
                rateLimiter.executeSuspendFunction {
                    timeLimiter.executeSuspendFunction {
                        bulkhead.executeSuspendFunction {
                            http.get().uri("/reviews/{isbn}", isbn)
                                .retrieve()
                                .bodyToFlux(Review::class.java)
                                .collectList()
                                .awaitSingle()
                        }
                    }
                }
            }
        }

    /** Streaming: the Flow operators, applied innermost first. */
    fun reviewsFlow(isbn: String): Flow<Review> =
        fetch(isbn)
            .bulkhead(bulkhead)
            .timeLimiter(timeLimiter)
            .rateLimiter(rateLimiter)
            .circuitBreaker(circuitBreaker)
            .retry(retry)
}

Source: Resilience4j: resilience4j-kotlin, Kotlin: Asynchronous Flow and the CircuitBreaker.kt source at v2.4.0. The imports are the executeSuspendFunction and the Flow operators from the matching io.github.resilience4j.kotlin.<pattern> packages, plus asFlow from kotlinx.coroutines.reactive and awaitSingle from kotlinx.coroutines.reactor, which is why the Maven snippet has kotlinx-coroutines-reactor. Compiled with the Kotlin Maven plugin of Boot’s parent (Kotlin 2.3.21) and run on Boot 4.1.1 and 3.5.16. The same four scenarios as the Java styles gave the same results for the cases that were run (see the table below).

The nesting rules are the ones you already know. In executeSuspendFunction the outermost block is the outermost pattern; in the Flow chain the last operator is the outermost one, which is the reverse of how it reads. The readme.io page makes the same point: in flowOf(1).retry(…​).timeLimiter(…​) all the retry attempts must fit in the time limiter’s duration.

Configuration DSLs

The module also has Kotlin builders for configurations and registries, so a config can be written without custom() and build().

A circuit breaker with the Kotlin DSL
val breaker: CircuitBreaker = CircuitBreaker.of("reviews", CircuitBreakerConfig {
    slidingWindowSize(10)
    minimumNumberOfCalls(5)
    failureRateThreshold(50f)
    waitDurationInOpenState(Duration.ofSeconds(10))
})

Source: CircuitBreakerConfig.kt at v2.4.0. In Spring, YAML and the auto-configured registries are usually the better source of configuration.

Cancellation and what is recorded

Structured concurrency adds a fourth outcome to success, failure and rejection: cancellation. The Kotlin extensions treat it as not a call result. The rules below come from Cancellation.kt and the extension sources, and the first two were measured.

Situation What the extension does

Coroutine cancelled while a call is inside CircuitBreaker.executeSuspendFunction

Releases the permission and records nothing: in a run the breaker had buffered 0 calls after cancelAndJoin.

The same with executeSuspendFunctionAndRecordCancellationError

Records the cancellation as a failure: buffered 1, failed 1. Use it when a cancel means that the caller gave up on a slow backend.

Bulkhead

On cancellation the permit is released and no event is recorded; a normal or failed end completes the call.

TimeLimiter

Uses withTimeout. A timeout is rethrown as java.util.concurrent.TimeoutException (observed message: "TimeLimiter 'reviews' recorded a timeout exception."), a cancellation that is not a timeout is rethrown without being recorded. The cancelRunningFuture setting is not used: the suspended block is always cancelled.

RateLimiter

Waits with delay, so the coroutine suspends instead of parking: in a run, with one permit per 300 ms, the second call waited 305 ms without blocking its thread.

Bulkhead with maxWaitDuration above zero

The wait is a blocking acquirePermission moved to Dispatchers.IO. On a single-thread dispatcher a ticker coroutine kept running while a bulkhead waited 600 ms and then failed with BulkheadFullException.

Source: Cancellation.kt, Bulkhead.kt and TimeLimiter.kt at v2.4.0, and Kotlin: Cancellation and timeouts. The readme.io page says that a time limiter raises TimeoutCancellationException "rather than a TimeoutException`"; the 2.4.0 extension converts it, and the runs show `TimeoutException.

The two lines that keep these rules explicit in code:

Choosing whether a cancellation counts
suspend fun cancellationIsNotAFailure(isbn: String): List<Review> =
    breaker.executeSuspendFunction { reviews.reviews(isbn) }

suspend fun cancellationRecorded(isbn: String): List<Review> =
    breaker.executeSuspendFunctionAndRecordCancellationError { reviews.reviews(isbn) }

One cancellation trap was found with Retry: its executeSuspendFunction catches every Exception, and CancellationException is one. With a waitDuration of 50 ms a cancelled coroutine ran its block once and stopped at the next delay; with waitDuration zero, which skips the cancellable delay, the block was invoked on all five attempts after the cancel. Keep a non-zero wait for suspending retries.

What the Kotlin module does not cover

Gap What to do

ThreadPoolBulkhead

Only its config and registry DSLs exist; there is no suspend or Flow extension (the readme.io page says the same). A coroutine already is the unit that you limit: use the semaphore Bulkhead or a Semaphore of your own.

Cache

There is no extension. Cache the suspend function’s result with the caching layer of the application, see Cache & Hedge.

Hedge

There is no extension (the resilience4j-hedge module has no Kotlin or Reactor support).

Spring annotations on suspend functions

Not handled. In a run, a @CircuitBreaker method that suspended for 20 ms and then threw was recorded by the breaker as a success, and a @Retry on a failing suspend method called it once instead of three times; the caller still received the exception. Use the extensions, or adapt the function to a Mono as below.

Auto-configuration

None. There is no Kotlin-specific starter; the registries and the YAML are the ones of the Java starter.

retryOnResult and similar predicates

Work as in Java, because the extensions call the same Retry and CircuitBreaker contexts.

Source: Resilience4j: resilience4j-kotlin and the resilience4j-kotlin sources at v2.4.0.

Suspending code and the Reactor operators can be combined. If annotations are what you need, expose the function as a Mono with mono { } from kotlinx-coroutines-reactor and decorate that:

A suspend function adapted to a Mono
fun asMono(isbn: String): Mono<List<Review>> =
    mono { reviews.reviews(isbn) }
        .transformDeferred(CircuitBreakerOperator.of(breaker))

Source: Spring Framework: Coroutines and the Kotlin coroutines overview. Run: the Mono returned its value and the breaker recorded one success.

Virtual threads: the blocking alternative

Everything above exists because blocking a thread used to be expensive. On Java 21 a blocked virtual thread costs almost nothing, and the question becomes whether you need a reactive pipeline for resilience at all. For the Bookshelf the honest answer is often no: the plain annotated methods of the earlier pages, called from virtual threads, give the same protection with code that reads top to bottom. The three concurrency models, their transaction stories and the trade-offs are on Concurrency Alternatives; the JDK mechanics are on Java virtual threads; and the bulkhead specifics, including the measured numbers, are in the Bulkhead: virtual threads section.

Concern Reactive pipeline (Mono, Flux, Single, Flow) Blocking code on virtual threads

Retry wait

Mono.delay or delay: no thread held

Thread.sleep inside the decorator: parks a virtual thread, which is cheap

Rate limiter wait

Delays the subscription

Parks the calling virtual thread up to timeoutDuration

Bulkhead wait

Zero, or the subscribing thread blocks

A non-zero maxWaitDuration parks only a virtual thread

Time limit

Reactor timeout, which also cancels the HTTP exchange

@TimeLimiter needs a CompletionStage or a reactive type; use the HTTP client’s timeouts, see TimeLimiter

Thread-pool bulkhead

Not available for reactive types

Available, but its pool uses platform threads

Cancellation

Part of the contract of every operator

Thread interruption

Debugging

Assembly-time versus subscribe-time behaviour, work that hops between schedulers

Ordinary stack traces, one thread per call

Streaming and backpressure

Native

You manage it yourself

The Bookshelf version of the call, on virtual threads (spring.threads.virtual.enabled: true), is the annotation style of the synchronous pages, with the semaphore bulkhead doing the limiting:

The same protection without reactive types
@Component
public class BlockingReviews {

    @Retry(name = "reviews")
    @CircuitBreaker(name = "reviews")
    @Bulkhead(name = "vt")
    public List<Review> reviews(String isbn) throws InterruptedException {
        Thread.sleep(100);   // stands in for a blocking RestClient call
        return List.of(new Review(isbn, "ann", 5));
    }
}

Source: Spring Boot: Task execution and scheduling and JEP 444: Virtual Threads. Run with a vt bulkhead of maxConcurrentCalls: 5 and maxWaitDuration: 10s: 50 virtual threads called the method, all 50 completed, the peak concurrency inside it was 5 and the batch took 1.05 s (ten waves of five); every call ran on a virtual thread, and a @Bulkhead(type = THREADPOOL) method reported that its body did not.

Pick the style by what the code already is, not by the pattern.

flowchart TD A([New resilient call]) --> B{Is the surrounding code already reactive, WebFlux, a Flux that is streamed, or an RxJava pipeline?} B -->|yes| C[Use the operators or the annotations on Mono and Flux] B -->|no| D{Kotlin coroutines in use?} D -->|yes| E[Use executeSuspendFunction and the Flow operators] D -->|no| F{Java 21 with virtual threads?} F -->|yes| G[Annotations on blocking methods, semaphore bulkhead, HTTP client timeouts] F -->|no| H[Annotations on blocking methods, with a thread pool large enough for the waits]
Figure 3. Choosing a style

Mixing is fine at a boundary, and wrong in the middle of a call. A WebFlux controller that calls a blocking, annotated method blocks an event-loop thread; a blocking service that calls block() on a decorated Mono gives up the point of using one.

The three styles side by side

The same four scenarios were run against WireMock for the Reactor operators, the annotations and the Kotlin extensions. The reviews configuration is the YAML above, in particular maxAttempts: 3, a 6-call window and a 400 ms limit.

Scenario Operators and annotations (Java, with the fallback) Kotlin suspend (no fallback) Kotlin Flow (no fallback)

503, 503, 200

2 reviews, 3 HTTP calls; breaker 2 failed, 1 successful

2 reviews, 3 HTTP calls; breaker 2 failed, 1 successful

2 reviews, 3 HTTP calls; breaker 2 failed, 1 successful

404 for an unknown book

Empty list, 1 HTTP call; breaker buffered 0

WebClientResponseException$NotFound thrown, 1 HTTP call; breaker buffered 0

WebClientResponseException$NotFound thrown, 1 HTTP call; breaker buffered 0

Body delayed by 1000 ms

Empty list after 3 attempts of about 400 ms (1.27 s in total), 3 HTTP calls, breaker 3 failed

TimeoutException, 3 HTTP calls

TimeoutException, 3 HTTP calls

Backend down, 4 calls in a row

6 HTTP calls, breaker OPEN; the 5th call made no HTTP call

6 HTTP calls, breaker OPEN; the 5th call failed with CallNotPermittedException, no HTTP call

6 HTTP calls, breaker OPEN; the 5th call failed with CallNotPermittedException, no HTTP call

Source: the output of the examples above on Boot 4.1.1; every row was identical on Boot 3.5.16. The Kotlin client has no fallback, so the failures reach the caller; add catch or onCompletion to degrade. The delayed-body row shows that a retry outside the time limiter spends the whole budget on every attempt: three attempts of 400 ms cost 1.2 s, so a deadline that the caller sees has to be set around the whole call, see TimeLimiter: a time budget across retries. The last row follows from the 6-call window: two logical calls of three attempts each fill it, the breaker opens, and every later attempt is rejected by the breaker without an HTTP call (the retry still retries a CallNotPermittedException, which is its default, see Retry).

The Java rows show that annotations and operators are equivalent for Mono and Flux, so the choice is style: annotations when the methods are Spring beans and the pattern is the same for every call, operators when the decoration depends on the call, when the code is not a Spring bean, or when the aspect cannot reach it (self-invocation, private methods, see Annotations and aspect order).

Pitfalls

Pitfall Fix

resilience4j-reactor missing: annotations on Mono and Flux record success and protect nothing

Add the module. There is no startup error. Test one failing call per annotated reactive method.

transform instead of transformDeferred with RetryOperator

Always use transformDeferred; the second subscriber of a transform pipeline got one attempt instead of three.

RxJava compose with RetryTransformer outside defer

Build the chain inside Single.defer (or Flowable.defer), so each subscriber gets a new retry context.

A non-zero maxWaitDuration on the bulkhead of a reactive call

The subscribing thread blocks. Use zero in reactive code and let the fallback answer.

A fallback on the inner annotation

The retry never sees a failure. Declare fallbackMethod on the outermost annotation.

A Mono built from a value that is already computed, or a hot source

Retries replay the same value. Wrap the call in Mono.defer or Mono.fromSupplier.

Work that can throw before the method returns the Mono

The fallback is not called for it. Keep the method body to assembling the pipeline.

Resuming on the parallel scheduler after a retry or a rate-limit delay

Attempts after the first run there. Use publishOn before blocking or thread-bound work, and never block it.

A Flux that fails midway is retried from the start

Items are emitted again. Retry only idempotent, restartable streams, or retry per element.

Time limiter on a Flux taken to mean a deadline for the sequence

It is a limit between signals. Use Flux.timeout on the whole sequence for an overall deadline.

@CircuitBreaker or @Retry on a Kotlin suspend function

Not supported: failures are not seen. Use executeSuspendFunction or mono { }.

Retry with waitDuration zero in a coroutine

A cancelled coroutine can burn all remaining attempts. Keep a small non-zero wait.

Counting cancellations as failures, or not

Decide with executeSuspendFunctionAndRecordCancellationError; the default ignores cancellation.

TimeLimiterOperator timeouts treated as the Resilience4j message

Match on TimeoutException and not on a message.

Blocking inside a WebFlux handler with an annotated method

Reactive containers have few threads. Keep blocking calls on virtual threads, or boundedElastic.

What changed recently

  • 0.15.0 made the Reactor and RxJava 2 operators acquire the permission before the subscription happens, which is the behaviour described above.

  • 1.2.0 added the Kotlin Flow operators, 1.4.0 added RxJava 3 and the non-coroutine Kotlin wrappers, and 1.5.0 added the Kotlin DSLs for configs and registries.

  • 1.7.0 fixed the Kotlin time limiter throwing after the coroutine was cancelled.

  • 2.3.0 added RxJava 3 support to the Spring aspects, aligned Retry.executeSuspendFunction with FlowRetry, and replaced synchronized with ReentrantLock in the core modules, which removed the thread pinning that affected virtual threads.

  • 2.4.0 adds resilience4j-spring-boot4; the Reactor, RxJava and Kotlin modules are unchanged in their API. The release notes are in the GitHub release; RELEASENOTES.adoc at the tag stops at 2.3.0.

  • The readme.io pages for these modules say the reactive library is not a transitive dependency (the 2.4.0 POMs declare it), show jCenter() and compile, call CircuitBreaker.ofDefaults() without a name (it needs one), and describe a TimeoutCancellationException where 2.4.0 throws TimeoutException. Follow the source.