From c8d8111223debfe5223df92ddb5abf6a3c56754b Mon Sep 17 00:00:00 2001 From: KENDAL Date: Wed, 12 Aug 2026 14:47:09 +0300 Subject: [PATCH 1/3] fix(timer-utils): cap exponential backoff factor and format attempt count string cleanly --- .../main/scala/com/daml/timer/RetryStrategy.scala | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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)))) From a5e93b7c9a599e372c6fe9dda1fcc31d2a822e55 Mon Sep 17 00:00:00 2001 From: KENDAL Date: Thu, 13 Aug 2026 12:37:18 +0300 Subject: [PATCH 2/3] test(timer-utils): add unit test for RetryStrategy overflow and message formatting --- sdk/timer-utils/BUILD.bazel | 10 ++++++ .../com/daml/timer/RetryStrategyTest.scala | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 sdk/timer-utils/src/test/scala/com/daml/timer/RetryStrategyTest.scala 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/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..ef1663e115cb --- /dev/null +++ b/sdk/timer-utils/src/test/scala/com/daml/timer/RetryStrategyTest.scala @@ -0,0 +1,34 @@ +// 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 { + // Large attempt count (e.g. 1024) causes math.pow(2.0, 1024.0) to equal Double.PositiveInfinity. + // Without capping, multiplying FiniteDuration * Infinity throws an overflow exception. + noException should be thrownBy { + RetryStrategy.exponentialBackoff(attempts = 1024, firstWaitTime = 10.millis) + } + } + + "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") + } + } + } +} From 5d95bbb51d5943b41d4fbda9728e1427cc955ea2 Mon Sep 17 00:00:00 2001 From: KENDAL Date: Thu, 13 Aug 2026 22:49:03 +0300 Subject: [PATCH 3/3] test(timer-utils): add succeed assertion return in AsyncWordSpec test --- .../src/test/scala/com/daml/timer/RetryStrategyTest.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index ef1663e115cb..ddf9eeca1252 100644 --- a/sdk/timer-utils/src/test/scala/com/daml/timer/RetryStrategyTest.scala +++ b/sdk/timer-utils/src/test/scala/com/daml/timer/RetryStrategyTest.scala @@ -11,11 +11,10 @@ class RetryStrategyTest extends AsyncWordSpec with Matchers { "RetryStrategy.exponentialBackoff" should { "not throw overflow exception when attempts count is large" in { - // Large attempt count (e.g. 1024) causes math.pow(2.0, 1024.0) to equal Double.PositiveInfinity. - // Without capping, multiplying FiniteDuration * Infinity throws an overflow exception. noException should be thrownBy { RetryStrategy.exponentialBackoff(attempts = 1024, firstWaitTime = 10.millis) } + succeed } "format attempts count string cleanly without printing Option wrapper" in {