Skip to content

Commit 6bab43e

Browse files
authored
fix: correct token bucket delays when experiencing system time jumps (#810)
1 parent 921dec7 commit 6bab43e

File tree

3 files changed

+22
-15
lines changed

3 files changed

+22
-15
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "9e04fd2c-136d-490b-9ecf-d6072a6f34c1",
3+
"type": "bugfix",
4+
"description": "Fix a bug where system time jumps could cause unexpected retry behavior",
5+
"issues": [
6+
"awslabs/smithy-kotlin#805"
7+
]
8+
}

runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/retries/delay/StandardRetryTokenBucket.kt

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,31 @@
66
package aws.smithy.kotlin.runtime.retries.delay
77

88
import aws.smithy.kotlin.runtime.retries.policy.RetryErrorType
9-
import aws.smithy.kotlin.runtime.time.Clock
10-
import aws.smithy.kotlin.runtime.time.epochMilliseconds
119
import kotlinx.coroutines.delay
1210
import kotlinx.coroutines.sync.Mutex
1311
import kotlinx.coroutines.sync.withLock
1412
import kotlin.math.ceil
1513
import kotlin.math.floor
1614
import kotlin.math.min
15+
import kotlin.time.ExperimentalTime
16+
import kotlin.time.TimeSource
1717

1818
private const val MS_PER_S = 1_000
1919

2020
/**
2121
* The standard implementation of a [RetryTokenBucket].
2222
* @param options The configuration to use for this bucket.
23-
* @param clock A clock to use for time calculations.
23+
* @param timeSource A monotonic time source to use for calculating the temporal token fill of the bucket.
2424
*/
25-
public class StandardRetryTokenBucket(
25+
@OptIn(ExperimentalTime::class)
26+
public class StandardRetryTokenBucket constructor(
2627
public val options: StandardRetryTokenBucketOptions = StandardRetryTokenBucketOptions.Default,
27-
private val clock: Clock = Clock.System,
28+
private val timeSource: TimeSource = TimeSource.Monotonic,
2829
) : RetryTokenBucket {
2930
internal var capacity = options.maxCapacity
3031
private set
3132

32-
private var lastTimestamp = now()
33+
private var lastTimeMark = timeSource.markNow()
3334
private val mutex = Mutex()
3435

3536
/**
@@ -58,13 +59,11 @@ public class StandardRetryTokenBucket(
5859
capacity = 0
5960
}
6061

61-
lastTimestamp = now()
62+
lastTimeMark = timeSource.markNow()
6263
}
6364

64-
private fun now(): Long = clock.now().epochMilliseconds
65-
6665
private fun refillCapacity() {
67-
val refillMs = now() - lastTimestamp
66+
val refillMs = lastTimeMark.elapsedNow().inWholeMilliseconds
6867
val refillSize = floor(options.refillUnitsPerSecond.toDouble() / MS_PER_S * refillMs).toInt()
6968
capacity = min(options.maxCapacity, capacity + refillSize)
7069
}
@@ -73,7 +72,7 @@ public class StandardRetryTokenBucket(
7372
refillCapacity()
7473

7574
capacity = min(options.maxCapacity, capacity + size)
76-
lastTimestamp = now()
75+
lastTimeMark = timeSource.markNow()
7776
}
7877

7978
/**

runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/retries/delay/StandardRetryTokenBucketTest.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
package aws.smithy.kotlin.runtime.retries.delay
77

88
import aws.smithy.kotlin.runtime.retries.policy.RetryErrorType
9-
import aws.smithy.kotlin.runtime.time.ManualClock
109
import kotlinx.coroutines.ExperimentalCoroutinesApi
1110
import kotlinx.coroutines.test.runTest
1211
import kotlin.test.Test
1312
import kotlin.test.assertEquals
1413
import kotlin.test.assertIs
1514
import kotlin.time.Duration.Companion.milliseconds
1615
import kotlin.time.ExperimentalTime
16+
import kotlin.time.TestTimeSource
1717

1818
class StandardRetryTokenBucketTest {
1919
companion object {
@@ -88,17 +88,17 @@ class StandardRetryTokenBucketTest {
8888
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
8989
@Test
9090
fun testRefillOverTime() = runTest {
91-
val clock = ManualClock()
91+
val timeSource = TestTimeSource()
9292

9393
// A bucket that costs capacity for an initial try
94-
val bucket = StandardRetryTokenBucket(DefaultOptions.copy(initialTryCost = 5), clock)
94+
val bucket = StandardRetryTokenBucket(DefaultOptions.copy(initialTryCost = 5), timeSource)
9595

9696
assertEquals(10, bucket.capacity)
9797
assertTime(0) { bucket.acquireToken() }
9898
assertEquals(5, bucket.capacity)
9999

100100
// Refill rate is 10/s == 1/100ms so after 250ms we should have 2 more tokens.
101-
clock.advance(250.milliseconds)
101+
timeSource += 250.milliseconds
102102

103103
assertTime(0) { bucket.acquireToken() }
104104
assertEquals(2, bucket.capacity) // We had 5, 2 refilled, and then we decremented 5 more.

0 commit comments

Comments
 (0)