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 |
|
|
Yes, on methods that return |
RxJava 3 |
|
|
Yes, on |
RxJava 2 |
|
The same classes in packages without the |
Yes, same types |
Kotlin |
|
|
No |
Blocking, on virtual threads |
the core modules and the starter |
Annotations, |
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):
<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.
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
Monodecorated 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
CircuitBreakerSubscriberandBulkheadSubscriberin the source).
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 |
|---|---|---|
|
Calls |
On a |
|
Resubscribes to the source on error, through Reactor’s own |
Attempts after the first run on Reactor’s |
|
Calls |
It never parks a thread. In a run with one permit per 400 ms, the second |
|
Calls |
With |
|
Applies Reactor’s |
The failure is Reactor’s |
|
Records a Micrometer |
It is the operator behind |
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.
@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.
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.
@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. AfallbackMethodon@CircuitBreakerwould answer before the@Retryever 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 returnedFluxwithonErrorResume(ReactorFallbackDecorator). A synchronous exception thrown by the method body, before it returns theFlux, is not routed to the fallback. Keep the body to assembling the pipeline. -
The method must return a cold publisher. A retry resubscribes.
WebClientpipelines are cold: each subscription is a new HTTP exchange. AMono.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.NotFoundgoes to the first method and everything else to the second. BecauseNotFoundis inignoreExceptions, 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.
The order is a decision, not a detail. Two runs show what it changes.
| Chain | Observed | Why |
|---|---|---|
|
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. |
|
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. |
|
Breaker buffered 1 call, 1 failed |
The timeout is an error that travels outward through the breaker, which records it. |
|
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 |
|
|
RateLimiter |
|
|
Bulkhead |
|
|
Retry |
|
|
TimeLimiter |
|
|
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.
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.
@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:
@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().
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 |
Releases the permission and records nothing: in a run the breaker had buffered 0 calls after |
The same with |
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. |
|
On cancellation the permit is released and no event is recorded; a normal or failed end completes the call. |
|
Uses |
|
Waits with |
|
The wait is a blocking |
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:
suspend fun cancellationIsNotAFailure(isbn: String): List<Review> =
breaker.executeSuspendFunction { reviews.reviews(isbn) }
suspend fun cancellationRecorded(isbn: String): List<Review> =
breaker.executeSuspendFunctionAndRecordCancellationError { reviews.reviews(isbn) }
Source: CircuitBreaker.kt at v2.4.0.
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 |
|---|---|
|
Only its config and registry DSLs exist; there is no suspend or |
|
There is no extension. Cache the suspend function’s result with the caching layer of the application, see Cache & Hedge. |
|
There is no extension (the |
Spring annotations on |
Not handled. In a run, a |
Auto-configuration |
None. There is no Kotlin-specific starter; the registries and the YAML are the ones of the Java starter. |
|
Work as in Java, because the extensions call the same |
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:
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 |
|
|
Rate limiter wait |
Delays the subscription |
Parks the calling virtual thread up to |
Bulkhead wait |
Zero, or the subscribing thread blocks |
A non-zero |
Time limit |
Reactor |
|
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:
@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.
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) |
|---|---|---|---|
|
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 |
|
Empty list, 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 |
|
|
Backend down, 4 calls in a row |
6 HTTP calls, breaker |
6 HTTP calls, breaker |
6 HTTP calls, breaker |
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 |
|---|---|
|
Add the module. There is no startup error. Test one failing call per annotated reactive method. |
|
Always use |
RxJava |
Build the chain inside |
A non-zero |
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 |
A |
Retries replay the same value. Wrap the call in |
Work that can throw before the method returns the |
The fallback is not called for it. Keep the method body to assembling the pipeline. |
Resuming on the |
Attempts after the first run there. Use |
A |
Items are emitted again. Retry only idempotent, restartable streams, or retry per element. |
Time limiter on a |
It is a limit between signals. Use |
|
Not supported: failures are not seen. Use |
|
A cancelled coroutine can burn all remaining attempts. Keep a small non-zero wait. |
Counting cancellations as failures, or not |
Decide with |
|
Match on |
Blocking inside a WebFlux handler with an annotated method |
Reactive containers have few threads. Keep blocking calls on virtual threads, or |
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
Flowoperators, 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.executeSuspendFunctionwithFlowRetry, and replacedsynchronizedwithReentrantLockin 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.adocat 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()andcompile, callCircuitBreaker.ofDefaults()without a name (it needs one), and describe aTimeoutCancellationExceptionwhere 2.4.0 throwsTimeoutException. Follow the source.