Skip to content

Commit f37ce30

Browse files
authored
Add a retry throttle (#1689)
Motivation: To implement retries and hedging, transports need to be able to throttle attempts to svoid overloading servers. The uses a token based system where successful requests, ones which end with non-retryable status code, add tokens and those which fail remove tokens from the system. Successful requests add 1 token, failed requests remove `tokenRatio` tokens (typically less than 1). Modification: - Implement a retry throttle - Add a requirement to the `ClientTransport` Result: Retries can be throttled.
1 parent e595df4 commit f37ce30

File tree

3 files changed

+229
-0
lines changed

3 files changed

+229
-0
lines changed

Sources/GRPCCore/Transport/ClientTransport.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ public protocol ClientTransport: Sendable {
1919
associatedtype Inbound: (AsyncSequence & Sendable) where Inbound.Element == RPCResponsePart
2020
associatedtype Outbound: ClosableRPCWriterProtocol<RPCRequestPart>
2121

22+
/// Returns a throttle which gRPC uses to determine whether retries can be executed.
23+
///
24+
/// Client transports don't need to implement the throttle or interact with it beyond its
25+
/// creation. gRPC will record the results of requests to determine whether retries can be
26+
/// performed.
27+
var retryThrottle: RetryThrottle { get }
28+
2229
/// Establish and maintain a connection to the remote destination.
2330
///
2431
/// Maintains a long-lived connection, or set of connections, to a remote destination.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/// A throttle used to rate-limit retries and hedging attempts.
18+
///
19+
/// gRPC prevents servers from being overloaded by retries and hedging by using a token-based
20+
/// throttling mechanism at the transport level.
21+
///
22+
/// Each client transport maintains a throttle for the server it is connected to and gRPC records
23+
/// successful and failed RPC attempts. Successful attempts increment the number of tokens
24+
/// by ``tokenRatio`` and failed attempts decrement the available tokens by one. In the context
25+
/// of throttling, a failed attempt is one where the server terminates the RPC with a status code
26+
/// which is retryable or non fatal (as defined by ``RetryPolicy/retryableStatusCodes`` and
27+
/// ``HedgingPolicy/nonFatalStatusCodes``) or when the client receives a pushback response from
28+
/// the server.
29+
///
30+
/// See also [gRFC A6: client retries](https://github.com/grpc/proposal/blob/master/A6-client-retries.md).
31+
public struct RetryThrottle: Sendable {
32+
// Note: only three figures after the decimal point from the original token ratio are used so
33+
// all computation is done a scaled number of tokens (tokens * 1000). This allows us to do all
34+
// computation in integer space.
35+
36+
/// The number of tokens available, multiplied by 1000.
37+
private let scaledTokensAvailable: LockedValueBox<Int>
38+
/// The number of tokens, multiplied by 1000.
39+
private let scaledTokenRatio: Int
40+
/// The maximum number of tokens, multiplied by 1000.
41+
private let scaledMaximumTokens: Int
42+
/// The retry threshold, multiplied by 1000. If ``scaledTokensAvailable`` is above this then
43+
/// retries are permitted.
44+
private let scaledRetryThreshold: Int
45+
46+
/// Returns the throttling token ratio.
47+
///
48+
/// The number of tokens held by the throttle is incremented by this value for each successful
49+
/// response. In the context of throttling, a successful response is one which:
50+
/// - receives metadata from the server, or
51+
/// - is terminated with a non-retryable or fatal status code.
52+
///
53+
/// If the response is a pushback response then it is not considered to be successful, even if
54+
/// either of the preceding conditions are met.
55+
public var tokenRatio: Double {
56+
Double(self.scaledTokenRatio) / 1000
57+
}
58+
59+
/// The maximum number of tokens the throttle may hold.
60+
public var maximumTokens: Int {
61+
self.scaledMaximumTokens / 1000
62+
}
63+
64+
/// The number of tokens the throttle currently has.
65+
///
66+
/// If this value is less than or equal to the retry threshold (defined as `maximumTokens / 2`)
67+
/// then RPCs will not be retried and hedging will be disabled.
68+
public var tokens: Double {
69+
self.scaledTokensAvailable.withLockedValue {
70+
Double($0) / 1000
71+
}
72+
}
73+
74+
/// Returns whether retries and hedging are permitted at this time.
75+
public var isRetryPermitted: Bool {
76+
self.scaledTokensAvailable.withLockedValue {
77+
$0 > self.scaledRetryThreshold
78+
}
79+
}
80+
81+
/// Create a new throttle.
82+
///
83+
/// - Parameters:
84+
/// - maximumTokens: The maximum number of tokens available. Must be in the range `1...1000`.
85+
/// - tokenRatio: The number of tokens to increment the available tokens by for successful
86+
/// responses. See the documentation on this type for a description of what counts as a
87+
/// successful response. Note that only three decimal places are used from this value.
88+
/// - Precondition: `maximumTokens` must be in the range `1...1000`.
89+
/// - Precondition: `tokenRatio` must be `>= 0.001`.
90+
public init(maximumTokens: Int, tokenRatio: Double) {
91+
precondition(
92+
(1 ... 1000).contains(maximumTokens),
93+
"maximumTokens must be in the range 1...1000 (is \(maximumTokens))"
94+
)
95+
96+
let scaledTokenRatio = Int(tokenRatio * 1000)
97+
precondition(scaledTokenRatio > 0, "tokenRatio must be >= 0.001 (is \(tokenRatio))")
98+
99+
let scaledTokens = maximumTokens * 1000
100+
self.scaledMaximumTokens = scaledTokens
101+
self.scaledRetryThreshold = scaledTokens / 2
102+
self.scaledTokenRatio = scaledTokenRatio
103+
self.scaledTokensAvailable = LockedValueBox(scaledTokens)
104+
}
105+
106+
/// Records a success, adding a token to the throttle.
107+
@usableFromInline
108+
func recordSuccess() {
109+
self.scaledTokensAvailable.withLockedValue { value in
110+
value = min(self.scaledMaximumTokens, value &+ self.scaledTokenRatio)
111+
}
112+
}
113+
114+
/// Records a failure, removing tokens from the throttle.
115+
/// - Returns: Whether retries will now be throttled.
116+
@usableFromInline
117+
@discardableResult
118+
func recordFailure() -> Bool {
119+
self.scaledTokensAvailable.withLockedValue { value in
120+
value = max(0, value &- 1000)
121+
return value <= self.scaledRetryThreshold
122+
}
123+
}
124+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import XCTest
18+
19+
@testable import GRPCCore
20+
21+
final class RetryThrottleTests: XCTestCase {
22+
func testThrottleOnInit() {
23+
let throttle = RetryThrottle(maximumTokens: 10, tokenRatio: 0.1)
24+
// Start with max tokens, so permitted.
25+
XCTAssertTrue(throttle.isRetryPermitted)
26+
XCTAssertEqual(throttle.maximumTokens, 10)
27+
XCTAssertEqual(throttle.tokens, 10)
28+
XCTAssertEqual(throttle.tokenRatio, 0.1)
29+
}
30+
31+
func testThrottleIgnoresMoreThanThreeDecimals() {
32+
let throttle = RetryThrottle(maximumTokens: 10, tokenRatio: 0.1239)
33+
XCTAssertEqual(throttle.tokenRatio, 0.123)
34+
}
35+
36+
func testFailureReducesTokens() {
37+
let throttle = RetryThrottle(maximumTokens: 10, tokenRatio: 0.1)
38+
XCTAssertEqual(throttle.tokens, 10)
39+
XCTAssert(throttle.isRetryPermitted)
40+
41+
throttle.recordFailure()
42+
XCTAssertEqual(throttle.tokens, 9)
43+
XCTAssert(throttle.isRetryPermitted)
44+
45+
throttle.recordFailure()
46+
XCTAssertEqual(throttle.tokens, 8)
47+
XCTAssert(throttle.isRetryPermitted)
48+
49+
throttle.recordFailure()
50+
XCTAssertEqual(throttle.tokens, 7)
51+
XCTAssert(throttle.isRetryPermitted)
52+
53+
throttle.recordFailure()
54+
XCTAssertEqual(throttle.tokens, 6)
55+
XCTAssert(throttle.isRetryPermitted)
56+
57+
// Drop to threshold, retries no longer allowed.
58+
throttle.recordFailure()
59+
XCTAssertEqual(throttle.tokens, 5)
60+
XCTAssertFalse(throttle.isRetryPermitted)
61+
}
62+
63+
func testTokensCantDropBelowZero() {
64+
let throttle = RetryThrottle(maximumTokens: 10, tokenRatio: 0.1)
65+
for _ in 0 ..< 1000 {
66+
throttle.recordFailure()
67+
XCTAssertGreaterThanOrEqual(throttle.tokens, 0)
68+
}
69+
XCTAssertEqual(throttle.tokens, 0)
70+
}
71+
72+
func testSuccessIncreasesTokens() {
73+
let throttle = RetryThrottle(maximumTokens: 10, tokenRatio: 0.1)
74+
75+
// Drop to zero.
76+
for _ in 0 ..< 10 {
77+
throttle.recordFailure()
78+
}
79+
XCTAssertEqual(throttle.tokens, 0)
80+
81+
// Start recording successes.
82+
throttle.recordSuccess()
83+
XCTAssertEqual(throttle.tokens, 0.1)
84+
85+
throttle.recordSuccess()
86+
XCTAssertEqual(throttle.tokens, 0.2)
87+
88+
throttle.recordSuccess()
89+
XCTAssertEqual(throttle.tokens, 0.3)
90+
}
91+
92+
func testTokensCantRiseAboveMax() {
93+
let throttle = RetryThrottle(maximumTokens: 10, tokenRatio: 0.1)
94+
XCTAssertEqual(throttle.tokens, 10)
95+
throttle.recordSuccess()
96+
XCTAssertEqual(throttle.tokens, 10)
97+
}
98+
}

0 commit comments

Comments
 (0)