Skip to content

Commit 4691401

Browse files
authored
feat: RetrySettings (#32687)
1 parent 9228b45 commit 4691401

File tree

5 files changed

+412
-2
lines changed

5 files changed

+412
-2
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (C) 2025 Lightbend Inc. <https://www.lightbend.com>
3+
*/
4+
5+
package akka.pattern;
6+
7+
import com.typesafe.config.ConfigFactory;
8+
import java.time.Duration;
9+
import java.util.Optional;
10+
import org.junit.Test;
11+
import org.scalatestplus.junit.JUnitSuite;
12+
13+
public class RetrySettingsTest extends JUnitSuite {
14+
15+
@Test
16+
public void shouldCreateRetrySetting() {
17+
// mostly for testing compilation issues
18+
RetrySettings.create(2);
19+
RetrySettings.create(ConfigFactory.parseString("max-retries = 2"));
20+
RetrySettings.create(2).withFixedDelay(Duration.ofSeconds(1));
21+
RetrySettings.create(2)
22+
.withExponentialBackoff(Duration.ofSeconds(1), Duration.ofSeconds(2), 0.1);
23+
RetrySettings.create(2).withJavaDelayFunction(retryNum -> Optional.of(Duration.ofSeconds(1)));
24+
RetrySettings.create(2).withJavaDecider(__ -> true);
25+
}
26+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright (C) 2025 Lightbend Inc. <https://www.lightbend.com>
3+
*/
4+
5+
package akka.pattern
6+
7+
import akka.pattern.RetrySettings.ExponentialBackoffFunction
8+
import akka.pattern.RetrySettings.FixedDelayFunction
9+
import com.typesafe.config.ConfigFactory
10+
import org.scalatest.matchers.should.Matchers
11+
import org.scalatest.wordspec.AnyWordSpec
12+
13+
import scala.concurrent.duration.DurationInt
14+
15+
class RetrySettingsSpec extends AnyWordSpec with Matchers {
16+
17+
"RetrySettings" should {
18+
"create with default values" in {
19+
{
20+
val retrySettings = RetrySettings(2)
21+
22+
retrySettings.maxRetries shouldBe 2
23+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
24+
exp.minBackoff shouldBe 100.millis
25+
exp.maxBackoff shouldBe 400.millis
26+
exp.randomFactor should ===(0.2 +- 1e-6)
27+
}
28+
{
29+
val retrySettings = RetrySettings(4)
30+
31+
retrySettings.maxRetries shouldBe 4
32+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
33+
exp.minBackoff shouldBe 100.millis
34+
exp.maxBackoff shouldBe 1600.millis
35+
exp.randomFactor should ===(0.3 +- 1e-6)
36+
}
37+
{
38+
val retrySettings = RetrySettings(8)
39+
40+
retrySettings.maxRetries shouldBe 8
41+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
42+
exp.minBackoff shouldBe 100.millis
43+
exp.maxBackoff shouldBe 25600.millis
44+
exp.randomFactor should ===(0.5 +- 1e-6)
45+
}
46+
{
47+
val retrySettings = RetrySettings(10)
48+
49+
retrySettings.maxRetries shouldBe 10
50+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
51+
exp.minBackoff shouldBe 100.millis
52+
exp.maxBackoff shouldBe 1.minute
53+
exp.randomFactor should ===(0.6 +- 1e-6)
54+
}
55+
{
56+
val retrySettings = RetrySettings(100)
57+
58+
retrySettings.maxRetries shouldBe 100
59+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
60+
exp.minBackoff shouldBe 100.millis
61+
exp.maxBackoff shouldBe 1.minute
62+
exp.randomFactor should ===(1.0 +- 1e-6)
63+
}
64+
}
65+
66+
"override default values" in {
67+
val retrySettings = RetrySettings(2).withExponentialBackoff(1.second, 2.seconds, 0.5)
68+
69+
retrySettings.maxRetries shouldBe 2
70+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
71+
exp.minBackoff shouldBe 1.second
72+
exp.maxBackoff shouldBe 2.seconds
73+
exp.randomFactor shouldBe 0.5
74+
}
75+
76+
"create from config" in {
77+
{
78+
val retrySettings = RetrySettings(ConfigFactory.parseString("max-retries = 2"))
79+
retrySettings.maxRetries shouldBe 2
80+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
81+
exp.minBackoff shouldBe 100.millis
82+
exp.maxBackoff shouldBe 400.millis
83+
exp.randomFactor should ===(0.2 +- 1e-6)
84+
}
85+
{
86+
val retrySettings = RetrySettings(ConfigFactory.parseString("""
87+
|max-retries = 2
88+
|fixed-delay = 500ms
89+
|""".stripMargin))
90+
retrySettings.maxRetries shouldBe 2
91+
retrySettings.delayFunction.asInstanceOf[FixedDelayFunction].delay shouldBe 500.millis
92+
}
93+
{
94+
val retrySettings = RetrySettings(ConfigFactory.parseString("""
95+
|max-retries = 2
96+
|min-backoff = 500ms
97+
|max-backoff = 1s
98+
|random-factor = 0.5
99+
|""".stripMargin))
100+
retrySettings.maxRetries shouldBe 2
101+
val exp = retrySettings.delayFunction.asInstanceOf[ExponentialBackoffFunction]
102+
exp.minBackoff shouldBe 500.millis
103+
exp.maxBackoff shouldBe 1.second
104+
exp.randomFactor should ===(0.5 +- 1e-6)
105+
}
106+
}
107+
}
108+
}

akka-actor/src/main/scala/akka/pattern/Patterns.scala

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,40 @@ object Patterns {
723723
(attempted) => delayFunction.apply(attempted).toScala.map(_.toScala))(context, scheduler).asJava
724724
}
725725

726+
/**
727+
* Returns an internally retrying [[java.util.concurrent.CompletionStage]].
728+
* The first attempt will be made immediately, each subsequent attempt will be made based on the provided [[akka.pattern.RetrySettings]].
729+
*
730+
* If attempts are exhausted, the returned CompletionStage is that of the last attempt.
731+
* Note that the attempt function will be executed on the actor system's dispatcher for subsequent tries
732+
* and therefore must be thread safe (not touch unsafe mutable state).
733+
*/
734+
def retry[T](
735+
attempt: Callable[CompletionStage[T]],
736+
retrySettings: RetrySettings,
737+
system: ClassicActorSystemProvider): CompletionStage[T] = {
738+
retry(attempt, retrySettings, system.classicSystem.scheduler, system.classicSystem.dispatcher)
739+
}
740+
741+
/**
742+
* Returns an internally retrying [[java.util.concurrent.CompletionStage]].
743+
* The first attempt will be made immediately, each subsequent attempt will be made based on the provided [[akka.pattern.RetrySettings]].
744+
* A scheduler (e.g. context.system().scheduler()) must be provided to delay retries.
745+
*
746+
* If attempts are exhausted, the returned CompletionStage is that of the last attempt.
747+
* Note that the attempt function will be invoked on the given execution context for subsequent tries and therefore
748+
* must be thread safe (not touch unsafe mutable state).
749+
*/
750+
def retry[T](
751+
attempt: Callable[CompletionStage[T]],
752+
retrySettings: RetrySettings,
753+
scheduler: Scheduler,
754+
context: ExecutionContext): CompletionStage[T] = {
755+
scalaRetry(retrySettings) { () =>
756+
attempt.call().asScala
757+
}(context, scheduler).asJava
758+
}
759+
726760
/**
727761
* Calculates an exponential back off delay.
728762
*/
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
* Copyright (C) 2025 Lightbend Inc. <https://www.lightbend.com>
3+
*/
4+
5+
package akka.pattern
6+
7+
import akka.annotation.InternalApi
8+
import akka.pattern.RetrySettings.ExponentialBackoffFunction
9+
import akka.pattern.RetrySettings.FixedDelayFunction
10+
import akka.pattern.RetrySupport.calculateExponentialBackoffDelay
11+
import com.typesafe.config.Config
12+
13+
import java.time.Duration
14+
import java.util.Optional
15+
import scala.concurrent.duration.DurationInt
16+
import scala.concurrent.duration.FiniteDuration
17+
import scala.jdk.DurationConverters.JavaDurationOps
18+
19+
/**
20+
* Settings for retrying operations.
21+
* @param maxRetries maximum number of retries
22+
* @param delayFunction function to calculate the delay between retries
23+
* @param shouldRetry function to determine if a failure should be retried
24+
*/
25+
final class RetrySettings private (
26+
val maxRetries: Int,
27+
val delayFunction: Int => Option[FiniteDuration],
28+
val shouldRetry: Throwable => Boolean = _ => true) {
29+
30+
override def toString: String = {
31+
s"RetrySettings(maxRetries=$maxRetries, delayFunction=$delayFunction)"
32+
}
33+
34+
/**
35+
* Scala API: Set exponential backoff delay between retries.
36+
*
37+
* @param minBackoff minimum backoff duration
38+
* @param maxBackoff maximum backoff duration
39+
* @param randomFactor random factor to add jitter to the backoff
40+
*/
41+
def withExponentialBackoff(
42+
minBackoff: FiniteDuration,
43+
maxBackoff: FiniteDuration,
44+
randomFactor: Double): RetrySettings =
45+
new RetrySettings(maxRetries, new ExponentialBackoffFunction(minBackoff, maxBackoff, randomFactor))
46+
47+
/**
48+
* Java API: Set exponential backoff delay between retries.
49+
*
50+
* @param minBackoff minimum backoff duration
51+
* @param maxBackoff maximum backoff duration
52+
* @param randomFactor random factor to add jitter to the backoff
53+
* @return Updated settings
54+
*/
55+
def withExponentialBackoff(minBackoff: Duration, maxBackoff: Duration, randomFactor: Double): RetrySettings =
56+
withExponentialBackoff(minBackoff.toScala, maxBackoff.toScala, randomFactor)
57+
58+
/**
59+
* Scala API: Set fixed delay between retries.
60+
* @param fixedDelay fixed delay between retries
61+
* @return Updated settings
62+
*/
63+
def withFixedDelay(fixedDelay: FiniteDuration): RetrySettings =
64+
new RetrySettings(maxRetries, new FixedDelayFunction(fixedDelay))
65+
66+
/**
67+
* Java API: Set fixed delay between retries.
68+
* @param fixedDelay fixed delay between retries
69+
* @return Updated settings
70+
*/
71+
def withFixedDelay(fixedDelay: Duration): RetrySettings =
72+
withFixedDelay(fixedDelay.toScala)
73+
74+
/**
75+
* Scala API: Set custom delay function between retries.
76+
* @param delayFunction function to calculate the delay between retries
77+
* @return Updated settings
78+
*/
79+
def withDelayFunction(delayFunction: Int => Option[FiniteDuration]): RetrySettings =
80+
new RetrySettings(maxRetries, delayFunction)
81+
82+
/**
83+
* Java API: Set custom delay function between retries.
84+
* @param delayFunction function to calculate the delay between retries
85+
* @return Updated settings
86+
*/
87+
def withJavaDelayFunction(
88+
delayFunction: java.util.function.IntFunction[Optional[java.time.Duration]]): RetrySettings = {
89+
val scalaFunc: Int => Option[FiniteDuration] = attempt => {
90+
val javaDuration = delayFunction.apply(attempt)
91+
if (javaDuration.isPresent)
92+
Some(javaDuration.get.toScala)
93+
else
94+
None
95+
}
96+
withDelayFunction(scalaFunc)
97+
}
98+
99+
/**
100+
* Scala API: Set the function to determine if a failure should be retried.
101+
* @param shouldRetry function to determine if a failure should be retried
102+
* @return Updated settings
103+
*/
104+
def withDecider(shouldRetry: Throwable => Boolean): RetrySettings =
105+
new RetrySettings(maxRetries, delayFunction, shouldRetry)
106+
107+
/**
108+
* Java API: Set the function to determine if a failure should be retried.
109+
* @param shouldRetry function to determine if a failure should be retried
110+
* @return Updated settings
111+
*/
112+
def withJavaDecider(shouldRetry: java.util.function.Function[Throwable, java.lang.Boolean]): RetrySettings =
113+
withDecider(shouldRetry.apply)
114+
}
115+
116+
object RetrySettings {
117+
118+
/**
119+
* INTERNAL API.
120+
*/
121+
@InternalApi
122+
private[akka] final class ExponentialBackoffFunction(
123+
val minBackoff: FiniteDuration,
124+
val maxBackoff: FiniteDuration,
125+
val randomFactor: Double)
126+
extends Function1[Int, Option[FiniteDuration]] {
127+
128+
override def apply(attempt: Int): Option[FiniteDuration] = {
129+
Some(calculateExponentialBackoffDelay(attempt, minBackoff, maxBackoff, randomFactor))
130+
}
131+
132+
override def toString: String = {
133+
s"Exponential(minBackoff=$minBackoff, maxBackoff=$maxBackoff, randomFactor=$randomFactor)"
134+
}
135+
}
136+
137+
/**
138+
* INTERNAL API.
139+
*/
140+
@InternalApi
141+
private[akka] final class FixedDelayFunction(val delay: FiniteDuration)
142+
extends Function1[Int, Option[FiniteDuration]] {
143+
144+
override def apply(attempt: Int): Option[FiniteDuration] = {
145+
Some(delay)
146+
}
147+
148+
override def toString: String = {
149+
s"Fixed($delay)"
150+
}
151+
}
152+
153+
/**
154+
* Scala API: Create settings with exponential backoff delay between retries.
155+
* The exponential backoff settings are calculated based on number of retries.
156+
* @param maxRetries maximum number of retries
157+
* @return RetrySettings with exponential backoff delay
158+
*/
159+
def apply(maxRetries: Int): RetrySettings = {
160+
// Start with a reasonable minimum backoff (e.g., 100 milliseconds)
161+
val minBackoff: FiniteDuration = 100.millis
162+
163+
// Max backoff increases with maxRetries, capped to a sensible upper limit (e.g., 1 minute)
164+
val maxBackoff: FiniteDuration = if (maxRetries > 10) { //to avoid multiplication overflow
165+
1.minute
166+
} else {
167+
val base = minBackoff * math.pow(2, maxRetries).toLong
168+
base.min(1.minute)
169+
}
170+
171+
// Random factor can scale slightly with maxRetries to add more jitter
172+
val randomFactor: Double = (0.1 + (maxRetries * 0.05)).min(1.0) // cap at 1.0
173+
174+
new RetrySettings(maxRetries, new ExponentialBackoffFunction(minBackoff, maxBackoff, randomFactor))
175+
}
176+
177+
/**
178+
* Scala API: Create settings with exponential backoff delay between retries.
179+
* The exponential backoff settings are calculated based on number of retries.
180+
* @param maxRetries maximum number of retries
181+
* @return RetrySettings with exponential backoff delay
182+
*/
183+
def create(maxRetries: Int): RetrySettings = {
184+
apply(maxRetries)
185+
}
186+
187+
/**
188+
* Scala API: Create settings from configuration.
189+
*/
190+
def apply(config: Config): RetrySettings = {
191+
val maxRetries = config.getInt("max-retries")
192+
if (config.hasPath("fixed-delay")) {
193+
val fixedDelay = config.getDuration("fixed-delay").toScala
194+
RetrySettings(maxRetries).withFixedDelay(fixedDelay)
195+
} else if (config.hasPath("min-backoff")) {
196+
val minBackoff = config.getDuration("min-backoff").toScala
197+
val maxBackoff = config.getDuration("max-backoff").toScala
198+
val randomFactor = config.getDouble("random-factor")
199+
RetrySettings(maxRetries).withExponentialBackoff(minBackoff, maxBackoff, randomFactor)
200+
} else
201+
RetrySettings(maxRetries)
202+
}
203+
204+
/**
205+
* Java API: Create settings from configuration.
206+
*/
207+
def create(config: Config): RetrySettings = {
208+
apply(config)
209+
}
210+
211+
}

0 commit comments

Comments
 (0)