Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions sdk/timer-utils/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
load(
"//bazel_tools:scala.bzl",
"da_scala_library",
"da_scala_test",
)

da_scala_library(
Expand All @@ -15,3 +16,12 @@ da_scala_library(
],
deps = [],
)

da_scala_test(
name = "timer-utils-tests",
srcs = glob(["src/test/scala/**/*.scala"]),
deps = [
":timer-utils",
"//3rdparty/jvm/org/scalatest",
],
)
14 changes: 11 additions & 3 deletions sdk/timer-utils/src/main/scala/com/daml/timer/RetryStrategy.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,21 @@ object RetryStrategy {

/** Retry a fixed amount of times with exponential backoff, regardless of the exception thrown
*/
def exponentialBackoff(attempts: Int, firstWaitTime: Duration): RetryStrategy =
def exponentialBackoff(attempts: Int, firstWaitTime: Duration): RetryStrategy = {
val cap = firstWaitTime match {
case fd: FiniteDuration =>
val maxFactor = math.pow(2.0, attempts.toDouble.min(62.0))
fd * maxFactor
case other => other
}
new RetryStrategy(
Some(attempts),
firstWaitTime,
firstWaitTime * math.pow(2.0, attempts.toDouble),
cap,
_ * 2,
{ case _ => true },
)
}

/** Retry a fixed amount of times with constant wait time, regardless of the exception thrown
*/
Expand Down Expand Up @@ -92,8 +99,9 @@ final class RetryStrategy private (
run(attempt, wait).recoverWith { case throwable =>
if (attempts.exists(attempt >= _)) {
val timeTaken = Duration.fromNanos(System.nanoTime() - startTime)
val attemptsStr = attempts.fold(attempt.toString)(_.toString)
val message =
s"Gave up trying after $attempts attempts and ${timeTaken.toUnit(SECONDS)} seconds."
s"Gave up trying after $attemptsStr attempts and ${timeTaken.toUnit(SECONDS)} seconds."
Future.failed(TooManyAttemptsException(attempt, timeTaken, message, throwable))
} else if (predicate.lift(throwable).getOrElse(false)) {
Delayed.Future.by(wait)(go(attempt + 1, clip(progression(wait))))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package com.daml.timer

import org.scalatest.wordspec.AsyncWordSpec
import org.scalatest.matchers.should.Matchers
import scala.concurrent.duration._

class RetryStrategyTest extends AsyncWordSpec with Matchers {

"RetryStrategy.exponentialBackoff" should {
"not throw overflow exception when attempts count is large" in {
noException should be thrownBy {
RetryStrategy.exponentialBackoff(attempts = 1024, firstWaitTime = 10.millis)
}
succeed
}

"format attempts count string cleanly without printing Option wrapper" in {
val strategy = RetryStrategy.exponentialBackoff(attempts = 1, firstWaitTime = 1.milli)
strategy { (_, _) =>
scala.concurrent.Future.failed(new RuntimeException("test error"))
}.failed.map {
case ex: RetryStrategy.TooManyAttemptsException =>
ex.getMessage should include("after 1 attempts")
ex.getMessage should not include "Some(1)"
case other =>
fail(s"Unexpected exception type: $other")
}
}
}
}
Loading