diff --git a/sdk/timer-utils/BUILD.bazel b/sdk/timer-utils/BUILD.bazel index 58d4d68e1840..48037eeceb6a 100644 --- a/sdk/timer-utils/BUILD.bazel +++ b/sdk/timer-utils/BUILD.bazel @@ -4,6 +4,7 @@ load( "//bazel_tools:scala.bzl", "da_scala_library", + "da_scala_test", ) da_scala_library( @@ -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", + ], +) diff --git a/sdk/timer-utils/src/main/scala/com/daml/timer/RetryStrategy.scala b/sdk/timer-utils/src/main/scala/com/daml/timer/RetryStrategy.scala index 48011195ad32..bebec13958dc 100644 --- a/sdk/timer-utils/src/main/scala/com/daml/timer/RetryStrategy.scala +++ b/sdk/timer-utils/src/main/scala/com/daml/timer/RetryStrategy.scala @@ -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 */ @@ -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)))) diff --git a/sdk/timer-utils/src/test/scala/com/daml/timer/RetryStrategyTest.scala b/sdk/timer-utils/src/test/scala/com/daml/timer/RetryStrategyTest.scala new file mode 100644 index 000000000000..ddf9eeca1252 --- /dev/null +++ b/sdk/timer-utils/src/test/scala/com/daml/timer/RetryStrategyTest.scala @@ -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") + } + } + } +}