Description:
Currently, Riptide creates a new instance of FailsafePlugin for each policy, and each FailsafePlugin in turn creates its own Failsafe instance. As a result, multiple policies (such as Retry and Timeout) are applied as separate plugins rather than being composed into a single chained policy.
This implementation choice predates key evolutions in the Failsafe library, and it now causes us to bypass Failsafe’s deadlock prevention logic as documented in reference 3 and 4.
By splitting policies into separate plugins, each policy creates its own Failsafe instance, resulting in independent execution and future contexts for each policy. This means Riptide clients are not protected by Failsafe’s built-in guardrails designed to prevent deadlocks between certain policy combinations such as Timeout and Retry.
Expected Behavior:
Riptide should compose all configured Failsafe policies into a single chain within one FailsafePlugin/Failsafe instance per endpoint/client, rather than instantiating one plugin/instance per policy.
Example
val retryPolicy = RetryPolicy.builder<String>()
.handle(Exception::class.java)
.withDelay(Duration.ofSeconds(1))
.withMaxRetries(3)
.onRetry { event ->
println("[Retry] Attempt #${event.attemptCount} | Reason: ${event.lastException?.message}")
}
.build()
val timeout = Timeout.builder<String>(Duration.ofSeconds(2))
.withInterrupt()
.onFailure { println("[Timeout] Execution timed out!") }
.build()
Failsafe.with(timeout, retryPolicy )
.getStageAsync { context -> fetchDataAsync() }
.thenAccept { result ->
println("✅ Result: $result")
}
.exceptionally { ex ->
println("❌ Failed: ${ex.message}")
null
}
Actual Behavior:
Each policy is configured as its own FailsafePlugin, resulting in unchained, independent Failsafe instances that do not benefit from Failsafe’s coordinated policy execution and deadlock prevention.
Current behavior
// timeout
val timeout = Timeout.builder<String>(Duration.ofSeconds(2))
.withInterrupt()
.onFailure { println("${Thread.currentThread().name} : [Timeout] Execution timed out!") }
.build()
// timeout
// Retry Policy
val retryPolicy = RetryPolicy.builder<String>()
.handle(Exception::class.java)
.withDelay(Duration.ofSeconds(1))
.withMaxRetries(3)
.onRetry { event ->
println("[Retry] Attempt #${event.attemptCount} | Reason: ${event.lastException?.message}")
}
.build()
//Failsafe with retry policy and the main task
val retryStage = Failsafe.with(retryPolicy)
.getStageAsync { context -> fetchDataAsync(atomicInteger.incrementAndGet()) }
Failsafe.with(timeout )
.getStageAsync { context -> retryStage } // Returns CompletableFuture
.thenAccept { result ->
println("✅ Result: $result")
}
.exceptionally { ex ->
println("❌ Failed: ${ex.message}")
null
}
References:
- FailsafePluginFactory : https://github.com/zalando/riptide/blob/main/riptide-spring-boot-autoconfigure/src/main/java/org/zalando/riptide/autoconfigure/FailsafePluginFactory.java#L45
- FailsafePlugin.java: https://github.com/zalando/riptide/blob/main/riptide-failsafe/src/main/java/org/zalando/riptide/failsafe/FailsafePlugin.java#L76
- Failsafe TimeoutExecutor Deadlock Guard: https://github.com/failsafe-lib/failsafe/blob/master/core/src/main/java/dev/failsafe/internal/TimeoutExecutor.java#L118
- Failsafe RetryPolicyExecutor deadlock guard: https://github.com/failsafe-lib/failsafe/blob/master/core/src/main/java/dev/failsafe/internal/RetryPolicyExecutor.java#L152
Steps to Reproduce
I used this test to reproduce this issue by adding some delays through debug points in
@Test
fun testFailsafeDeadlock(){
// timeout
val timeout = Timeout.builder<String>(Duration.ofSeconds(2))
.withInterrupt()
.onFailure { println("${Thread.currentThread().name} : [Timeout] Execution timed out!") }
.build()
// timeout
// Retry Policy
val retryPolicy = RetryPolicy.builder<String>()
.handle(Exception::class.java)
.withDelay(Duration.ofSeconds(1))
.withMaxRetries(3)
.onRetry { event ->
println("[Retry] Attempt #${event.attemptCount} | Reason: ${event.lastException?.message}")
}
.build()
//Failsafe with retry policy and the main task
val retryStage = Failsafe.with(retryPolicy)
.getStageAsync { context -> fetchDataAsync(atomicInteger.incrementAndGet()) }
Failsafe.with(timeout )
.getStageAsync { context -> retryStage } // Returns CompletableFuture
.thenAccept { result ->
println("✅ Result: $result")
}
.exceptionally { ex ->
println("❌ Failed: ${ex.message}")
null
}
.join() // Block main thread until done
}
fun fetchDataAsync(incrementAndGet: Int): CompletableFuture<String> {
return CompletableFuture.supplyAsync {
println("[Service] Call $incrementAndGet Fetching data on thread: ${Thread.currentThread().name}")
if(incrementAndGet==1){
throw Exception("Failing")
}
Thread.sleep(1100)
"Hello from async service!"
}
}
Debug points
- FailsafeFuture L117
- TimeoutExecutor L125
- TimeoutExecutor L119
- RetryPolicyExecutor L145
Context
This led to a deadlock in one of our services, which blocked the forked join pool from being used by other processes, leading to degradation of the performance of that service.
Thread dump for investigation
Found one Java-level deadlock:
=============================
"ForkJoinPool.commonPool-worker-3":
waiting to lock monitor 0x000079025c01e1c0 (object 0x00001000f287dc00, a dev.failsafe.spi.FailsafeFuture),
which is held by "http-my-service-234"
"http-my-service-234":
waiting to lock monitor 0x00007902bc000eb0 (object 0x00001000f2882aa0, a dev.failsafe.spi.FailsafeFuture),
which is held by "ForkJoinPool.commonPool-worker-3"
Java stack information for the threads listed above:
===================================================
"ForkJoinPool.commonPool-worker-3":
at dev.failsafe.spi.FailsafeFuture.cancel(FailsafeFuture.java:74)
- waiting to lock <0x00001000f287dc00> (a dev.failsafe.spi.FailsafeFuture)
at dev.failsafe.spi.FailsafeFuture.lambda$propagateCancellation$0(FailsafeFuture.java:175)
at dev.failsafe.spi.FailsafeFuture$$Lambda/0x000079042ccb5a70.accept(Unknown Source)
at dev.failsafe.spi.FailsafeFuture.cancelDependencies(FailsafeFuture.java:129)
- locked <0x00001000f2882aa0> (a dev.failsafe.spi.FailsafeFuture)
at dev.failsafe.internal.TimeoutExecutor.lambda$applyAsync$2(TimeoutExecutor.java:133)
- locked <0x00001000f2882be0> (a java.util.concurrent.atomic.AtomicReference)
at dev.failsafe.internal.TimeoutExecutor$$Lambda/0x000079042cfe2000.call(Unknown Source)
at dev.failsafe.internal.util.DelegatingScheduler.lambda$schedule$0(DelegatingScheduler.java:141)
at dev.failsafe.internal.util.DelegatingScheduler$$Lambda/0x000079042ccaed68.call(Unknown Source)
at io.opentelemetry.javaagent.bootstrap.executors.ContextPropagatingCallable.call(ContextPropagatingCallable.java:38)
at java.util.concurrent.ForkJoinTask$AdaptedCallable.exec(java.base@21.0.10/ForkJoinTask.java:1456)
at java.util.concurrent.ForkJoinTask.doExec(java.base@21.0.10/ForkJoinTask.java:387)
at java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(java.base@21.0.10/ForkJoinPool.java:1312)
at java.util.concurrent.ForkJoinPool.scan(java.base@21.0.10/ForkJoinPool.java:1843)
at java.util.concurrent.ForkJoinPool.runWorker(java.base@21.0.10/ForkJoinPool.java:1808)
at java.util.concurrent.ForkJoinWorkerThread.run(java.base@21.0.10/ForkJoinWorkerThread.java:188)
"http-my-service-234":
at dev.failsafe.spi.FailsafeFuture.completeResult(FailsafeFuture.java:93)
- waiting to lock <0x00001000f2882aa0> (a dev.failsafe.spi.FailsafeFuture)
at dev.failsafe.AsyncExecutionImpl.complete(AsyncExecutionImpl.java:153)
at dev.failsafe.AsyncExecutionImpl$$Lambda/0x000079042ccb4d08.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.internal.TimeoutExecutor.lambda$applyAsync$4(TimeoutExecutor.java:174)
at dev.failsafe.internal.TimeoutExecutor$$Lambda/0x000079042cfe28e8.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.Functions.lambda$toAsync$5(Functions.java:202)
at dev.failsafe.Functions$$Lambda/0x000079042ccb60f0.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.Functions.lambda$getPromiseOfStage$2(Functions.java:152)
at dev.failsafe.Functions$$Lambda/0x000079042ccb5ca8.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.spi.FailsafeFuture.completeResult(FailsafeFuture.java:99)
- locked <0x00001000f287dc00> (a dev.failsafe.spi.FailsafeFuture)
at dev.failsafe.AsyncExecutionImpl.complete(AsyncExecutionImpl.java:153)
at dev.failsafe.AsyncExecutionImpl$$Lambda/0x000079042ccb4d08.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.internal.RetryPolicyExecutor.lambda$handleAsync$5(RetryPolicyExecutor.java:155)
- locked <0x00001000f287dd50> (a java.util.concurrent.atomic.AtomicReference)
at dev.failsafe.internal.RetryPolicyExecutor$$Lambda/0x000079042cd6da58.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture.uniWhenCompleteStage(java.base@21.0.10/CompletableFuture.java:887)
at java.util.concurrent.CompletableFuture.whenComplete(java.base@21.0.10/CompletableFuture.java:2357)
at dev.failsafe.internal.RetryPolicyExecutor.lambda$handleAsync$6(RetryPolicyExecutor.java:150)
at dev.failsafe.internal.RetryPolicyExecutor$$Lambda/0x000079042ccaf430.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.internal.RetryPolicyExecutor.lambda$handleAsync$5(RetryPolicyExecutor.java:155)
- locked <0x00001000f287dd50> (a java.util.concurrent.atomic.AtomicReference)
at dev.failsafe.internal.RetryPolicyExecutor$$Lambda/0x000079042cd6da58.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture.uniWhenCompleteStage(java.base@21.0.10/CompletableFuture.java:887)
at java.util.concurrent.CompletableFuture.whenComplete(java.base@21.0.10/CompletableFuture.java:2357)
at dev.failsafe.internal.RetryPolicyExecutor.lambda$handleAsync$6(RetryPolicyExecutor.java:150)
at dev.failsafe.internal.RetryPolicyExecutor$$Lambda/0x000079042ccaf430.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.internal.RetryPolicyExecutor.lambda$handleAsync$5(RetryPolicyExecutor.java:155)
- locked <0x00001000f287dd50> (a java.util.concurrent.atomic.AtomicReference)
at dev.failsafe.internal.RetryPolicyExecutor$$Lambda/0x000079042cd6da58.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture.uniWhenCompleteStage(java.base@21.0.10/CompletableFuture.java:887)
at java.util.concurrent.CompletableFuture.whenComplete(java.base@21.0.10/CompletableFuture.java:2357)
at dev.failsafe.internal.RetryPolicyExecutor.lambda$handleAsync$6(RetryPolicyExecutor.java:150)
at dev.failsafe.internal.RetryPolicyExecutor$$Lambda/0x000079042ccaf430.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.Functions.lambda$toAsync$5(Functions.java:202)
at dev.failsafe.Functions$$Lambda/0x000079042ccb60f0.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.Functions.lambda$getPromiseOfStage$2(Functions.java:152)
at dev.failsafe.Functions$$Lambda/0x000079042ccb5ca8.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.spi.FailsafeFuture.completeResult(FailsafeFuture.java:99)
- locked <0x00001000f287c830> (a dev.failsafe.spi.FailsafeFuture)
at dev.failsafe.AsyncExecutionImpl.complete(AsyncExecutionImpl.java:153)
at dev.failsafe.AsyncExecutionImpl$$Lambda/0x000079042ccb4d08.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.spi.PolicyExecutor.lambda$applyAsync$2(PolicyExecutor.java:109)
at dev.failsafe.spi.PolicyExecutor$$Lambda/0x000079042ccb5838.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.Functions.lambda$toAsync$5(Functions.java:202)
at dev.failsafe.Functions$$Lambda/0x000079042ccb60f0.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.complete(java.base@21.0.10/CompletableFuture.java:2179)
at dev.failsafe.Functions.lambda$getPromiseOfStage$2(Functions.java:152)
at dev.failsafe.Functions$$Lambda/0x000079042ccb5ca8.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@21.0.10/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@21.0.10/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@21.0.10/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture$AsyncSupply.run(java.base@21.0.10/CompletableFuture.java:1773)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.10/ThreadPoolExecutor.java:1144)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.10/ThreadPoolExecutor.java:642)
at java.lang.Thread.runWith(java.base@21.0.10/Thread.java:1596)
at java.lang.Thread.run(java.base@21.0.10/Thread.java:1583)
Your Environment
Description:
Currently, Riptide creates a new instance of FailsafePlugin for each policy, and each FailsafePlugin in turn creates its own Failsafe instance. As a result, multiple policies (such as Retry and Timeout) are applied as separate plugins rather than being composed into a single chained policy.
This implementation choice predates key evolutions in the Failsafe library, and it now causes us to bypass Failsafe’s deadlock prevention logic as documented in reference 3 and 4.
By splitting policies into separate plugins, each policy creates its own Failsafe instance, resulting in independent execution and future contexts for each policy. This means Riptide clients are not protected by Failsafe’s built-in guardrails designed to prevent deadlocks between certain policy combinations such as Timeout and Retry.
Expected Behavior:
Riptide should compose all configured Failsafe policies into a single chain within one FailsafePlugin/Failsafe instance per endpoint/client, rather than instantiating one plugin/instance per policy.
Example
Actual Behavior:
Each policy is configured as its own FailsafePlugin, resulting in unchained, independent Failsafe instances that do not benefit from Failsafe’s coordinated policy execution and deadlock prevention.
Current behavior
References:
Steps to Reproduce
I used this test to reproduce this issue by adding some delays through debug points in
Debug points
Context
This led to a deadlock in one of our services, which blocked the forked join pool from being used by other processes, leading to degradation of the performance of that service.
Thread dump for investigation
Your Environment