Skip to content

Commit 01e00d2

Browse files
authored
Retries (#49)
Closes #48
1 parent 5b30b2a commit 01e00d2

17 files changed

+1106
-32
lines changed

README.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,6 @@ There are some helper methods which might be useful when writing forked code:
375375

376376
* `forever { ... }` repeatedly evaluates the given code block forever
377377
* `repeatWhile { ... }` repeatedly evaluates the given code block, as long as it returns `true`
378-
* `retry(times, sleep) { ... }` retries the given block up to the given number of times
379378
* `uninterruptible { ... }` evaluates the given code block making sure it can't be interrupted
380379

381380
## Syntax
@@ -694,6 +693,10 @@ Please see [the respective ADR](doc/adr/0001-error-propagation-in-channels.md) f
694693
Channels are back-pressured, as the `.send` operation is blocking until there's a receiver thread available, or if
695694
there's enough space in the buffer. The processing space is bound by the total size of channel buffers.
696695

696+
## Retries
697+
698+
The retries mechanism allows to retry a failing operation according to a given policy (e.g. retry 3 times with a 100ms delay between attempts). See the [full docs](doc/retries.md) for details.
699+
697700
## Kafka sources & drains
698701

699702
Dependency:

core/src/main/scala/ox/control.scala

-21
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package ox
22

3-
import scala.concurrent.duration.FiniteDuration
4-
53
def forever(f: => Unit): Nothing =
64
while true do f
75
throw new RuntimeException("can't get here")
@@ -16,25 +14,6 @@ def repeatUntil(f: => Boolean): Unit =
1614
var loop = true
1715
while loop do loop = !f
1816

19-
// TODO: retry schedules
20-
def retry[T](times: Int, sleep: FiniteDuration)(f: => T): T =
21-
try f
22-
catch
23-
case e: Throwable =>
24-
if times > 0
25-
then
26-
Thread.sleep(sleep.toMillis)
27-
retry(times - 1, sleep)(f)
28-
else throw e
29-
30-
def retryEither[E, T](times: Int, sleep: FiniteDuration)(f: => Either[E, T]): Either[E, T] =
31-
f match
32-
case r: Right[E, T] => r
33-
case Left(_) if times > 0 =>
34-
Thread.sleep(sleep.toMillis)
35-
retry(times - 1, sleep)(f)
36-
case l: Left[E, T] => l
37-
3817
def uninterruptible[T](f: => T): T =
3918
scoped {
4019
val t = fork(f)
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package ox.retry
2+
3+
/** A random factor used for calculating the delay between subsequent retries when a backoff strategy is used for calculating the delay.
4+
*
5+
* The purpose of jitter is to avoid clustering of subsequent retries, i.e. to reduce the number of clients calling a service exactly at
6+
* the same time - which can result in subsequent failures, contrary to what you would expect from retrying. By introducing randomness to
7+
* the delays, the retries become more evenly distributed over time.
8+
*
9+
* See the <a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/">AWS Architecture Blog article on backoff and
10+
* jitter</a> for a more in-depth explanation.
11+
*
12+
* Depending on the algorithm, the jitter can affect the delay in different ways - see the concrete variants for more details.
13+
*/
14+
enum Jitter:
15+
/** No jitter, i.e. the delay just uses an exponential backoff with no adjustments. */
16+
case None
17+
18+
/** Full jitter, i.e. the delay is a random value between 0 and the calculated backoff delay. */
19+
case Full
20+
21+
/** Equal jitter, i.e. the delay is half of the calculated backoff delay plus a random value between 0 and the other half. */
22+
case Equal
23+
24+
/** Decorrelated jitter, i.e. the delay is a random value between the initial delay and the last delay multiplied by 3. */
25+
case Decorrelated
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package ox.retry
2+
3+
/** A policy that allows to customize when a non-erroneous result is considered successful and when an error is worth retrying (which allows
4+
* for failing fast on certain errors).
5+
*
6+
* @param isSuccess
7+
* A function that determines whether a non-erroneous result is considered successful. By default, every non-erroneous result is
8+
* considered successful.
9+
* @param isWorthRetrying
10+
* A function that determines whether an error is worth retrying. By default, all errors are retried.
11+
* @tparam E
12+
* The error type of the operation. For operations returning a `T` or a `Try[T]`, this is fixed to `Throwable`. For operations returning
13+
* an `Either[E, T]`, this can be any `E`.
14+
* @tparam T
15+
* The successful result type for the operation.
16+
*/
17+
case class ResultPolicy[E, T](isSuccess: T => Boolean = (_: T) => true, isWorthRetrying: E => Boolean = (_: E) => true)
18+
19+
object ResultPolicy:
20+
/** A policy that considers every non-erroneous result successful and retries on any error. */
21+
def default[E, T]: ResultPolicy[E, T] = ResultPolicy()
22+
23+
/** A policy that customizes when a non-erroneous result is considered successful, and retries all errors
24+
*
25+
* @param isSuccess
26+
* A predicate that indicates whether a non-erroneous result is considered successful.
27+
*/
28+
def successfulWhen[E, T](isSuccess: T => Boolean): ResultPolicy[E, T] = ResultPolicy(isSuccess = isSuccess)
29+
30+
/** A policy that customizes which errors are retried, and considers every non-erroneous result successful
31+
* @param isWorthRetrying
32+
* A predicate that indicates whether an erroneous result should be retried..
33+
*/
34+
def retryWhen[E, T](isWorthRetrying: E => Boolean): ResultPolicy[E, T] = ResultPolicy(isWorthRetrying = isWorthRetrying)
35+
36+
/** A policy that considers every non-erroneous result successful and never retries any error, i.e. fails fast */
37+
def neverRetry[E, T]: ResultPolicy[E, T] = ResultPolicy(isWorthRetrying = _ => false)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package ox.retry
2+
3+
import scala.concurrent.duration.*
4+
5+
/** A policy that defines how to retry a failed operation.
6+
*
7+
* @param schedule
8+
* The retry schedule which determines the maximum number of retries and the delay between subsequent attempts to execute the operation.
9+
* See [[Schedule]] for more details.
10+
* @param resultPolicy
11+
* A policy that allows to customize when a non-erroneous result is considered successful and when an error is worth retrying (which
12+
* allows for failing fast on certain errors). See [[ResultPolicy]] for more details.
13+
* @tparam E
14+
* The error type of the operation. For operations returning a `T` or a `Try[T]`, this is fixed to `Throwable`. For operations returning
15+
* an `Either[E, T]`, this can be any `E`.
16+
* @tparam T
17+
* The successful result type for the operation.
18+
*/
19+
case class RetryPolicy[E, T](schedule: Schedule, resultPolicy: ResultPolicy[E, T] = ResultPolicy.default[E, T])
20+
21+
object RetryPolicy:
22+
/** Creates a policy that retries up to a given number of times, with no delay between subsequent attempts, using a default
23+
* [[ResultPolicy]].
24+
*
25+
* This is a shorthand for {{{RetryPolicy(Schedule.Immediate(maxRetries))}}}
26+
*
27+
* @param maxRetries
28+
* The maximum number of retries.
29+
*/
30+
def immediate[E, T](maxRetries: Int): RetryPolicy[E, T] = RetryPolicy(Schedule.Immediate(maxRetries))
31+
32+
/** Creates a policy that retries indefinitely, with no delay between subsequent attempts, using a default [[ResultPolicy]].
33+
*
34+
* This is a shorthand for {{{RetryPolicy(Schedule.Immediate.forever)}}}
35+
*/
36+
def immediateForever[E, T]: RetryPolicy[E, T] = RetryPolicy(Schedule.Immediate.forever)
37+
38+
/** Creates a policy that retries up to a given number of times, with a fixed delay between subsequent attempts, using a default
39+
* [[ResultPolicy]].
40+
*
41+
* This is a shorthand for {{{RetryPolicy(Schedule.Delay(maxRetries, delay))}}}
42+
*
43+
* @param maxRetries
44+
* The maximum number of retries.
45+
* @param delay
46+
* The delay between subsequent attempts.
47+
*/
48+
def delay[E, T](maxRetries: Int, delay: FiniteDuration): RetryPolicy[E, T] = RetryPolicy(Schedule.Delay(maxRetries, delay))
49+
50+
/** Creates a policy that retries indefinitely, with a fixed delay between subsequent attempts, using a default [[ResultPolicy]].
51+
*
52+
* This is a shorthand for {{{RetryPolicy(Schedule.Delay.forever(delay))}}}
53+
*
54+
* @param delay
55+
* The delay between subsequent attempts.
56+
*/
57+
def delayForever[E, T](delay: FiniteDuration): RetryPolicy[E, T] = RetryPolicy(Schedule.Delay.forever(delay))
58+
59+
/** Creates a policy that retries up to a given number of times, with an increasing delay (backoff) between subsequent attempts, using a
60+
* default [[ResultPolicy]].
61+
*
62+
* The backoff is exponential with base 2 (i.e. the next delay is twice as long as the previous one), starting at the given initial delay
63+
* and capped at the given maximum delay.
64+
*
65+
* This is a shorthand for {{{RetryPolicy(Schedule.Backoff(maxRetries, initialDelay, maxDelay, jitter))}}}
66+
*
67+
* @param maxRetries
68+
* The maximum number of retries.
69+
* @param initialDelay
70+
* The delay before the first retry.
71+
* @param maxDelay
72+
* The maximum delay between subsequent retries. Defaults to 1 minute.
73+
* @param jitter
74+
* A random factor used for calculating the delay between subsequent retries. See [[Jitter]] for more details. Defaults to no jitter,
75+
* i.e. an exponential backoff with no adjustments.
76+
*/
77+
def backoff[E, T](
78+
maxRetries: Int,
79+
initialDelay: FiniteDuration,
80+
maxDelay: FiniteDuration = 1.minute,
81+
jitter: Jitter = Jitter.None
82+
): RetryPolicy[E, T] =
83+
RetryPolicy(Schedule.Backoff(maxRetries, initialDelay, maxDelay, jitter))
84+
85+
/** Creates a policy that retries indefinitely, with an increasing delay (backoff) between subsequent attempts, using a default
86+
* [[ResultPolicy]].
87+
*
88+
* The backoff is exponential with base 2 (i.e. the next delay is twice as long as the previous one), starting at the given initial delay
89+
* and capped at the given maximum delay.
90+
*
91+
* This is a shorthand for {{{RetryPolicy(Schedule.Backoff.forever(initialDelay, maxDelay, jitter))}}}
92+
*
93+
* @param initialDelay
94+
* The delay before the first retry.
95+
* @param maxDelay
96+
* The maximum delay between subsequent retries. Defaults to 1 minute.
97+
* @param jitter
98+
* A random factor used for calculating the delay between subsequent retries. See [[Jitter]] for more details. Defaults to no jitter,
99+
* i.e. an exponential backoff with no adjustments.
100+
*/
101+
def backoffForever[E, T](
102+
initialDelay: FiniteDuration,
103+
maxDelay: FiniteDuration = 1.minute,
104+
jitter: Jitter = Jitter.None
105+
): RetryPolicy[E, T] =
106+
RetryPolicy(Schedule.Backoff.forever(initialDelay, maxDelay, jitter))
+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package ox.retry
2+
3+
import scala.concurrent.duration.*
4+
import scala.util.Random
5+
6+
private[retry] sealed trait Schedule:
7+
def nextDelay(attempt: Int, lastDelay: Option[FiniteDuration]): FiniteDuration
8+
9+
object Schedule:
10+
11+
private[retry] sealed trait Finite extends Schedule:
12+
def maxRetries: Int
13+
14+
private[retry] sealed trait Infinite extends Schedule
15+
16+
/** A schedule that retries up to a given number of times, with no delay between subsequent attempts.
17+
*
18+
* @param maxRetries
19+
* The maximum number of retries.
20+
*/
21+
case class Immediate(maxRetries: Int) extends Finite:
22+
override def nextDelay(attempt: Int, lastDelay: Option[FiniteDuration]): FiniteDuration = Duration.Zero
23+
24+
object Immediate:
25+
/** A schedule that retries indefinitely, with no delay between subsequent attempts. */
26+
def forever: Infinite = ImmediateForever
27+
28+
private case object ImmediateForever extends Infinite:
29+
override def nextDelay(attempt: Int, lastDelay: Option[FiniteDuration]): FiniteDuration = Duration.Zero
30+
31+
/** A schedule that retries up to a given number of times, with a fixed delay between subsequent attempts.
32+
*
33+
* @param maxRetries
34+
* The maximum number of retries.
35+
* @param delay
36+
* The delay between subsequent attempts.
37+
*/
38+
case class Delay(maxRetries: Int, delay: FiniteDuration) extends Finite:
39+
override def nextDelay(attempt: Int, lastDelay: Option[FiniteDuration]): FiniteDuration = delay
40+
41+
object Delay:
42+
/** A schedule that retries indefinitely, with a fixed delay between subsequent attempts.
43+
*
44+
* @param delay
45+
* The delay between subsequent attempts.
46+
*/
47+
def forever(delay: FiniteDuration): Infinite = DelayForever(delay)
48+
49+
case class DelayForever private[retry] (delay: FiniteDuration) extends Infinite:
50+
override def nextDelay(attempt: Int, lastDelay: Option[FiniteDuration]): FiniteDuration = delay
51+
52+
/** A schedule that retries up to a given number of times, with an increasing delay (backoff) between subsequent attempts.
53+
*
54+
* The backoff is exponential with base 2 (i.e. the next delay is twice as long as the previous one), starting at the given initial delay
55+
* and capped at the given maximum delay.
56+
*
57+
* @param maxRetries
58+
* The maximum number of retries.
59+
* @param initialDelay
60+
* The delay before the first retry.
61+
* @param maxDelay
62+
* The maximum delay between subsequent retries.
63+
* @param jitter
64+
* A random factor used for calculating the delay between subsequent retries. See [[Jitter]] for more details. Defaults to no jitter,
65+
* i.e. an exponential backoff with no adjustments.
66+
*/
67+
case class Backoff(
68+
maxRetries: Int,
69+
initialDelay: FiniteDuration,
70+
maxDelay: FiniteDuration = 1.minute,
71+
jitter: Jitter = Jitter.None
72+
) extends Finite:
73+
override def nextDelay(attempt: Int, lastDelay: Option[FiniteDuration]): FiniteDuration =
74+
Backoff.nextDelay(attempt, initialDelay, maxDelay, jitter, lastDelay)
75+
76+
object Backoff:
77+
private[retry] def delay(attempt: Int, initialDelay: FiniteDuration, maxDelay: FiniteDuration): FiniteDuration =
78+
// converting Duration <-> Long back and forth to avoid exceeding maximum duration
79+
(initialDelay.toMillis * Math.pow(2, attempt)).toLong.min(maxDelay.toMillis).millis
80+
81+
private[retry] def nextDelay(
82+
attempt: Int,
83+
initialDelay: FiniteDuration,
84+
maxDelay: FiniteDuration,
85+
jitter: Jitter,
86+
lastDelay: Option[FiniteDuration]
87+
): FiniteDuration =
88+
def backoffDelay = Backoff.delay(attempt, initialDelay, maxDelay)
89+
90+
jitter match
91+
case Jitter.None => backoffDelay
92+
case Jitter.Full => Random.between(0, backoffDelay.toMillis).millis
93+
case Jitter.Equal =>
94+
val backoff = backoffDelay.toMillis
95+
(backoff / 2 + Random.between(0, backoff / 2)).millis
96+
case Jitter.Decorrelated =>
97+
val last = lastDelay.getOrElse(initialDelay).toMillis
98+
Random.between(initialDelay.toMillis, last * 3).millis
99+
100+
/** A schedule that retries indefinitely, with an increasing delay (backoff) between subsequent attempts.
101+
*
102+
* The backoff is exponential with base 2 (i.e. the next delay is twice as long as the previous one), starting at the given initial
103+
* delay and capped at the given maximum delay.
104+
*
105+
* @param initialDelay
106+
* The delay before the first retry.
107+
* @param maxDelay
108+
* The maximum delay between subsequent retries.
109+
* @param jitter
110+
* A random factor used for calculating the delay between subsequent retries. See [[Jitter]] for more details. Defaults to no jitter,
111+
* i.e. an exponential backoff with no adjustments.
112+
*/
113+
def forever(initialDelay: FiniteDuration, maxDelay: FiniteDuration = 1.minute, jitter: Jitter = Jitter.None): Infinite =
114+
BackoffForever(initialDelay, maxDelay, jitter)
115+
116+
case class BackoffForever private[retry] (initialDelay: FiniteDuration, maxDelay: FiniteDuration = 1.minute, jitter: Jitter = Jitter.None)
117+
extends Infinite:
118+
override def nextDelay(attempt: Int, lastDelay: Option[FiniteDuration]): FiniteDuration =
119+
Backoff.nextDelay(attempt, initialDelay, maxDelay, jitter, lastDelay)

0 commit comments

Comments
 (0)