Skip to content

Commit 4c97397

Browse files
tokizuohgh-action-runner
authored andcommitted
feat: Add exponential backoff support to MaxRetryInterceptor (#711)
1 parent 1f8c59d commit 4c97397

File tree

2 files changed

+210
-11
lines changed

2 files changed

+210
-11
lines changed

Tests/ApolloTests/Interceptors/MaxRetryInterceptorTests.swift

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,118 @@ class MaxRetryInterceptorTests: XCTestCase {
100100

101101
self.wait(for: [expectation], timeout: 1)
102102
}
103+
104+
func testExponentialBackoffDoesNotBreakInterceptorChain() {
105+
// Test that exponential backoff preserves normal interceptor chain behavior
106+
class TestProvider: InterceptorProvider {
107+
let testInterceptor = RetryToCountThenSucceedInterceptor(timesToCallRetry: 2)
108+
let retryCount = 3
109+
110+
let mockClient: MockURLSessionClient = {
111+
let client = MockURLSessionClient()
112+
client.jsonData = [:]
113+
client.response = HTTPURLResponse(url: TestURL.mockServer.url,
114+
statusCode: 200,
115+
httpVersion: nil,
116+
headerFields: nil)
117+
return client
118+
}()
119+
120+
func interceptors<Operation: GraphQLOperation>(
121+
for operation: Operation
122+
) -> [any ApolloInterceptor] {
123+
let config = MaxRetryInterceptor.Configuration(
124+
maxRetries: self.retryCount,
125+
baseDelay: 0.001, // Very small delay to keep test fast
126+
multiplier: 2.0,
127+
maxDelay: 0.01,
128+
enableExponentialBackoff: true,
129+
enableJitter: false
130+
)
131+
return [
132+
MaxRetryInterceptor(configuration: config),
133+
self.testInterceptor,
134+
NetworkFetchInterceptor(client: self.mockClient),
135+
JSONResponseParsingInterceptor()
136+
]
137+
}
138+
}
139+
140+
let testProvider = TestProvider()
141+
let network = RequestChainNetworkTransport(interceptorProvider: testProvider,
142+
endpointURL: TestURL.mockServer.url)
143+
144+
let expectation = self.expectation(description: "Request completed successfully")
145+
146+
let operation = MockQuery.mock()
147+
_ = network.send(operation: operation) { result in
148+
defer {
149+
expectation.fulfill()
150+
}
151+
152+
switch result {
153+
case .success:
154+
// Verify that the chain completed successfully even with exponential backoff
155+
XCTAssertEqual(testProvider.testInterceptor.timesRetryHasBeenCalled, testProvider.testInterceptor.timesToCallRetry)
156+
case .failure(let error):
157+
XCTFail("Chain should have succeeded with exponential backoff: \(error)")
158+
}
159+
}
160+
161+
self.wait(for: [expectation], timeout: 2)
162+
}
163+
164+
func testExponentialBackoffPreservesErrorHandling() {
165+
// Test that exponential backoff doesn't interfere with proper error propagation
166+
class TestProvider: InterceptorProvider {
167+
let testInterceptor = BlindRetryingTestInterceptor()
168+
let retryCount = 2
169+
170+
func interceptors<Operation: GraphQLOperation>(
171+
for operation: Operation
172+
) -> [any ApolloInterceptor] {
173+
let config = MaxRetryInterceptor.Configuration(
174+
maxRetries: self.retryCount,
175+
baseDelay: 0.001,
176+
multiplier: 2.0,
177+
maxDelay: 0.01,
178+
enableExponentialBackoff: true,
179+
enableJitter: false
180+
)
181+
return [
182+
MaxRetryInterceptor(configuration: config),
183+
self.testInterceptor
184+
]
185+
}
186+
}
187+
188+
let testProvider = TestProvider()
189+
let network = RequestChainNetworkTransport(interceptorProvider: testProvider,
190+
endpointURL: TestURL.mockServer.url)
191+
192+
let expectation = self.expectation(description: "Request failed as expected")
193+
194+
let operation = MockQuery.mock()
195+
_ = network.send(operation: operation) { result in
196+
defer {
197+
expectation.fulfill()
198+
}
199+
200+
switch result {
201+
case .success:
202+
XCTFail("This should not have succeeded")
203+
case .failure(let error):
204+
// Verify that the correct error is propagated even with exponential backoff
205+
guard case MaxRetryInterceptor.RetryError.hitMaxRetryCount(let count, _) = error else {
206+
XCTFail("Unexpected error type: \(error)")
207+
return
208+
}
209+
XCTAssertEqual(count, testProvider.retryCount)
210+
// Verify that retries still happened correctly
211+
XCTAssertEqual(testProvider.testInterceptor.hitCount, testProvider.retryCount + 1)
212+
}
213+
}
214+
215+
self.wait(for: [expectation], timeout: 2)
216+
}
103217
}

apollo-ios/Sources/Apollo/MaxRetryInterceptor.swift

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,51 @@ import Foundation
33
import ApolloAPI
44
#endif
55

6-
/// An interceptor to enforce a maximum number of retries of any `HTTPRequest`
6+
/// An interceptor to enforce a maximum number of retries of any `HTTPRequest` with optional exponential backoff support
77
public class MaxRetryInterceptor: ApolloInterceptor {
88

9-
private let maxRetries: Int
9+
/// A configuration object that defines behavior for retry logic and exponential backoff.
10+
public struct Configuration {
11+
/// Maximum number of retries allowed. Defaults to `3`.
12+
public let maxRetries: Int
13+
/// Initial delay in seconds for exponential backoff. Defaults to `0.3`.
14+
public let baseDelay: TimeInterval
15+
/// Multiplier for exponential backoff calculation. Defaults to `2.0`.
16+
public let multiplier: Double
17+
/// Maximum delay cap in seconds to prevent excessive wait times. Defaults to `20.0`.
18+
public let maxDelay: TimeInterval
19+
/// Whether to enable exponential backoff delays between retries. Defaults to `false`.
20+
public let enableExponentialBackoff: Bool
21+
/// Whether to add jitter to delays to prevent thundering herd problems. Defaults to `true`.
22+
public let enableJitter: Bool
23+
24+
/// Designated initializer
25+
///
26+
/// - Parameters:
27+
/// - maxRetries: Maximum number of retries allowed.
28+
/// - baseDelay: Initial delay in seconds for exponential backoff.
29+
/// - multiplier: Multiplier for exponential backoff calculation. Should be ≥ 1.0.
30+
/// - maxDelay: Maximum delay cap in seconds to prevent excessive wait times.
31+
/// - enableExponentialBackoff: Whether to enable exponential backoff delays between retries.
32+
/// - enableJitter: Whether to add jitter to delays to prevent thundering herd problems.
33+
public init(
34+
maxRetries: Int = 3,
35+
baseDelay: TimeInterval = 0.3,
36+
multiplier: Double = 2.0,
37+
maxDelay: TimeInterval = 20.0,
38+
enableExponentialBackoff: Bool = false,
39+
enableJitter: Bool = true
40+
) {
41+
self.maxRetries = maxRetries
42+
self.baseDelay = baseDelay
43+
self.multiplier = multiplier
44+
self.maxDelay = maxDelay
45+
self.enableExponentialBackoff = enableExponentialBackoff
46+
self.enableJitter = enableJitter
47+
}
48+
}
49+
50+
private let configuration: Configuration
1051
private var hitCount = 0
1152

1253
public var id: String = UUID().uuidString
@@ -26,17 +67,24 @@ public class MaxRetryInterceptor: ApolloInterceptor {
2667
///
2768
/// - Parameter maxRetriesAllowed: How many times a query can be retried, in addition to the initial attempt before
2869
public init(maxRetriesAllowed: Int = 3) {
29-
self.maxRetries = maxRetriesAllowed
70+
self.configuration = Configuration(maxRetries: maxRetriesAllowed)
71+
}
72+
73+
/// Designated initializer with full configuration support.
74+
///
75+
/// - Parameter configuration: Configuration object defining retry behavior and exponential backoff settings.
76+
public init(configuration: Configuration) {
77+
self.configuration = configuration
3078
}
3179

3280
public func interceptAsync<Operation: GraphQLOperation>(
3381
chain: any RequestChain,
3482
request: HTTPRequest<Operation>,
3583
response: HTTPResponse<Operation>?,
3684
completion: @escaping (Result<GraphQLResult<Operation.Data>, any Error>) -> Void) {
37-
guard self.hitCount <= self.maxRetries else {
85+
guard self.hitCount <= self.configuration.maxRetries else {
3886
let error = RetryError.hitMaxRetryCount(
39-
count: self.maxRetries,
87+
count: self.configuration.maxRetries,
4088
operationName: Operation.operationName
4189
)
4290

@@ -51,11 +99,48 @@ public class MaxRetryInterceptor: ApolloInterceptor {
5199
}
52100

53101
self.hitCount += 1
54-
chain.proceedAsync(
55-
request: request,
56-
response: response,
57-
interceptor: self,
58-
completion: completion
59-
)
102+
103+
// Apply exponential backoff delay if enabled and this is a retry (hitCount > 1)
104+
if self.configuration.enableExponentialBackoff && self.hitCount > 1 {
105+
let delay = calculateExponentialBackoffDelay()
106+
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay) { [weak self] in
107+
guard let self = self else { return }
108+
chain.proceedAsync(
109+
request: request,
110+
response: response,
111+
interceptor: self,
112+
completion: completion
113+
)
114+
}
115+
} else {
116+
chain.proceedAsync(
117+
request: request,
118+
response: response,
119+
interceptor: self,
120+
completion: completion
121+
)
122+
}
123+
}
124+
125+
/// Calculates the exponential backoff delay based on current retry attempt.
126+
///
127+
/// - Returns: The calculated delay in seconds, including jitter if enabled.
128+
private func calculateExponentialBackoffDelay() -> TimeInterval {
129+
// Calculate exponential delay: baseDelay * multiplier^(hitCount - 1)
130+
// We use (hitCount - 1) because hitCount includes the initial attempt
131+
let retryAttempt = hitCount - 1
132+
let exponentialDelay = configuration.baseDelay * pow(configuration.multiplier, Double(retryAttempt))
133+
134+
// Apply maximum delay cap
135+
let cappedDelay = min(exponentialDelay, configuration.maxDelay)
136+
137+
// Apply jitter if enabled to prevent thundering herd problems
138+
if configuration.enableJitter {
139+
// Equal jitter: random value between 50% and 100% of calculated delay
140+
let minDelay = cappedDelay / 2
141+
return TimeInterval.random(in: minDelay...cappedDelay)
142+
} else {
143+
return cappedDelay
144+
}
60145
}
61146
}

0 commit comments

Comments
 (0)