Skip to content

Commit e8ceb05

Browse files
committed
some fixes
1 parent fb7fc3a commit e8ceb05

File tree

1 file changed

+98
-51
lines changed

1 file changed

+98
-51
lines changed

Sources/SwiftAPIClient/Modifiers/RetryModifier.swift

Lines changed: 98 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,39 @@ public extension APIClient.Configs {
7070
}
7171

7272
/// The interval between retries. It can be a fixed time interval or a closure that takes the current retry count and returns a time interval.
73+
/// Default to exponential backoff starting at 0.5 seconds, doubling each time, up to a maximum of 30 seconds.
7374
/// - Note: This configuration works only if you use the `retry()` modifier.
74-
var retryInterval: (Int) -> TimeInterval {
75-
get { self[\.retryInterval] ?? { _ in 1.0 } }
75+
var retryInterval: (_ attempt: Int, _ response: HTTPResponse?) -> TimeInterval {
76+
get {
77+
self[\.retryInterval] ?? { attempt, response in
78+
min(0.5 * pow(2.0, Double(attempt)), 30.0)
79+
}
80+
}
7681
set { self[\.retryInterval] = newValue }
7782
}
78-
83+
7984
/// The date formatter used to parse the `Retry-After` header when it contains a date. By default, it uses the RFC 1123 format.
8085
/// - Tips: `DateFormatter` creation is expensive, so if you need a custom format, create the formatter once and reuse it.
8186
var retryAfterHeaderDateFormatter: DateFormatter {
8287
get { self[\.retryAfterHeaderDateFormatter] ?? defaultRetryAfterHeaderDateFormatter }
8388
set { self[\.retryAfterHeaderDateFormatter] = newValue }
8489
}
8590

91+
/// The set of HTTP status codes that may include a `Retry-After` header. Default to `[429, 503]` due to RFC 7231.
92+
/// If a response has one of these status codes and includes a `Retry-After` header,
93+
/// the client will wait for the specified duration before retrying the request.
94+
/// - Note: This configuration works only if you use the `retry()` modifier.
95+
var retryAfterHeaderStatusCodes: Set<HTTPResponse.Status> {
96+
get { self[\.retryAfterHeaderStatusCodes] ?? [.tooManyRequests, .serviceUnavailable] }
97+
set { self[\.retryAfterHeaderStatusCodes] = newValue }
98+
}
99+
100+
/// Configuration for jitter applied to retry intervals.
101+
var retryJitterConfigs: RetryJitterConfigs {
102+
get { self[\.retryJitterConfigs] ?? RetryJitterConfigs() }
103+
set { self[\.retryJitterConfigs] = newValue }
104+
}
105+
86106
/// The backoff policy used to determine how to handle global backoff scenarios, such as rate limiting.
87107
/// - Note: This configuration works only if you use the `retry()` modifier.
88108
var retryBackoffPolicy: RetryBackoffPolicy {
@@ -110,15 +130,15 @@ public extension APIClient {
110130
/// Sets the interval between retries. It can be a fixed time interval or a closure that takes the current retry count and returns a time interval.
111131
/// - Parameter interval: A closure that takes the current retry count (starting from 0
112132
/// - Note: This configuration works only if you use the `retry()` modifier.
113-
func retryInterval(_ interval: @escaping (Int) -> TimeInterval) -> APIClient {
133+
func retryInterval(_ interval: @escaping (Int, HTTPResponse?) -> TimeInterval) -> APIClient {
114134
configs(\.retryInterval, interval)
115135
}
116136

117137
/// Sets a fixed interval between retries.
118138
/// - Parameter interval: The time interval to wait before the next retry.
119139
/// - Note: This configuration works only if you use the `retry()` modifier.
120140
func retryInterval(_ interval: TimeInterval) -> APIClient {
121-
retryInterval { _ in interval }
141+
retryInterval { _, _ in interval }
122142
}
123143

124144
/// Sets the date formatter used to parse the `Retry-After` header when it contains a date. By default, it uses the RFC 1123 format.
@@ -138,7 +158,8 @@ public extension APIClient {
138158

139159
/// Retries the request when necessary, based on the configured retry conditions and limits.
140160
/// - Note: Like any modifier, this is order dependent. It takes in account only error from previous modifiers but not the following ones.
141-
/// - Tip: You can customize the retry behavior by setting the `retryCondition`, `retryLimit`, `retryInterval`, and `retryBackoffPolicy` configurations.
161+
/// - Tip: You can customize the retry behavior by setting the `retryCondition`, `retryLimit`, `retryInterval`, `retryBackoffPolicy`,
162+
/// `retryAfterHeaderStatusCodes`, `retryAfterHeaderDateFormatter` and `retryJitterConfigs` configurations.
142163
func retry() -> APIClient {
143164
httpClientMiddleware(retryMiddleware())
144165
}
@@ -147,13 +168,13 @@ public extension APIClient {
147168
/// A condition that determines whether a request should be retried based on the request, the result of the request, and the client configurations.
148169
public struct RetryRequestCondition {
149170

150-
private let condition: (HTTPRequestComponents, Result<HTTPResponse, Error>, APIClient.Configs) -> Bool
171+
private let condition: (HTTPRequestComponents, HTTPResponse?, Error?, APIClient.Configs) -> Bool
151172

152173
/// Initializes a new `RetryRequestCondition` with a custom condition closure.
153174
/// - Parameters:
154175
/// - condition: A closure that takes the request, the result of the request, and the client configs, and returns a Boolean indicating whether to retry the request.
155176
public init(
156-
_ condition: @escaping (_ request: HTTPRequestComponents, _ result: Result<HTTPResponse, Error>, _ configs: APIClient.Configs) -> Bool
177+
_ condition: @escaping (_ request: HTTPRequestComponents, _ result: HTTPResponse?, _ error: Error?, _ configs: APIClient.Configs) -> Bool
157178
) {
158179
self.condition = condition
159180
}
@@ -165,19 +186,20 @@ public struct RetryRequestCondition {
165186
/// - configs: The configurations of the API client.
166187
public func shouldRetry(
167188
request: HTTPRequestComponents,
168-
result: Result<HTTPResponse, Error>,
189+
response: HTTPResponse?,
190+
error: Error?,
169191
configs: APIClient.Configs
170192
) -> Bool {
171-
condition(request, result, configs)
193+
condition(request, response, error, configs)
172194
}
173195

174196
/// Combines two `RetryRequestCondition` instances using a logical AND operation.
175197
/// The resulting condition will only return `true` if both conditions return `true`.
176198
/// - Parameter other: Another `RetryRequestCondition` to combine with.
177199
public func and(_ other: RetryRequestCondition) -> RetryRequestCondition {
178-
RetryRequestCondition { request, result, configs in
179-
self.shouldRetry(request: request, result: result, configs: configs)
180-
&& other.shouldRetry(request: request, result: result, configs: configs)
200+
RetryRequestCondition { request, response, error, configs in
201+
self.shouldRetry(request: request, response: response, error: error, configs: configs)
202+
&& other.shouldRetry(request: request, response: response, error: error, configs: configs)
181203
}
182204
}
183205

@@ -194,9 +216,9 @@ public struct RetryRequestCondition {
194216
/// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
195217
/// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
196218
public static func and(_ conditions: [RetryRequestCondition]) -> RetryRequestCondition {
197-
RetryRequestCondition { request, result, configs in
219+
RetryRequestCondition { request, response, error, configs in
198220
for condition in conditions {
199-
if !condition.shouldRetry(request: request, result: result, configs: configs) {
221+
if !condition.shouldRetry(request: request, response: response, error: error, configs: configs) {
200222
return false
201223
}
202224
}
@@ -217,9 +239,9 @@ public struct RetryRequestCondition {
217239
/// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
218240
/// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
219241
public static func or(_ conditions: [RetryRequestCondition]) -> RetryRequestCondition {
220-
RetryRequestCondition { request, result, configs in
242+
RetryRequestCondition { request, response, error, configs in
221243
for condition in conditions {
222-
if condition.shouldRetry(request: request, result: result, configs: configs) {
244+
if condition.shouldRetry(request: request, response: response, error: error, configs: configs) {
223245
return true
224246
}
225247
}
@@ -231,9 +253,9 @@ public struct RetryRequestCondition {
231253
/// The resulting condition will return `true` if either condition returns `true`.
232254
/// - Parameter other: Another `RetryRequestCondition` to combine with.
233255
public func or(_ other: RetryRequestCondition) -> RetryRequestCondition {
234-
RetryRequestCondition { request, result, configs in
235-
self.shouldRetry(request: request, result: result, configs: configs)
236-
|| other.shouldRetry(request: request, result: result, configs: configs)
256+
RetryRequestCondition { request, response, error, configs in
257+
self.shouldRetry(request: request, response: response, error: error, configs: configs)
258+
|| other.shouldRetry(request: request, response: response, error: error, configs: configs)
237259
}
238260
}
239261

@@ -254,17 +276,17 @@ public struct RetryRequestCondition {
254276
}
255277

256278
/// A `RetryRequestCondition` that retries safe HTTP methods (like GET) when the request fails due to error status codes or network errors.
257-
public static let requestFailed = RetryRequestCondition { request, result, _ in
258-
switch result {
259-
case let .success(response):
279+
public static let requestFailed = RetryRequestCondition { request, response, error, _ in
280+
switch error {
281+
case nil:
260282
return false
261-
case let .failure(error):
283+
case let .some(error):
262284
return !(error is CancellationError)
263285
}
264286
}
265287

266288
/// A `RetryRequestCondition` that retries the request when the HTTP method is considered safe (e.g., GET, HEAD, OPTIONS).
267-
public static let requestMethodIsSafe = RetryRequestCondition { request, _, _ in
289+
public static let requestMethodIsSafe = RetryRequestCondition { request, _, _, _ in
268290
request.method.isSafe
269291
}
270292

@@ -281,7 +303,7 @@ public struct RetryRequestCondition {
281303

282304
/// A `RetryRequestCondition` that retries requests with defined HTTP methods.
283305
public static func methods(_ methods: Set<HTTPRequest.Method>) -> RetryRequestCondition {
284-
RetryRequestCondition { request, _, _ in
306+
RetryRequestCondition { request, _, _, _ in
285307
methods.contains(request.method)
286308
}
287309
}
@@ -293,8 +315,8 @@ public struct RetryRequestCondition {
293315

294316
/// A `RetryRequestCondition` that retries when the response status code is one of the specified codes.
295317
public static func statusCodes(_ codes: Set<HTTPResponse.Status>) -> RetryRequestCondition {
296-
RetryRequestCondition { _, response, _ in
297-
if case let .success(response) = response {
318+
RetryRequestCondition { _, response, _, _ in
319+
if let response {
298320
return codes.contains(response.status)
299321
}
300322
return false
@@ -340,15 +362,15 @@ private struct retryMiddleware: HTTPClientMiddleware {
340362
let backoffPolicy = configs.retryBackoffPolicy
341363
if let hash = backoffPolicy.scopeHash(request) {
342364
if let interval = await waitForSynchronizedAccess(id: hash, of: UInt64.self) {
343-
try await Task.sleep(nanoseconds: jitterNs(interval))
365+
try await Task.sleep(nanoseconds: configs.retryJitterConfigs.delay(for: interval))
344366
}
345367
}
346368
var count = 0
347369
var response: HTTPResponse?
348370
var retryAfterHeader: TimeInterval = 0
349371

350-
func needRetry(_ result: Result<HTTPResponse, Error>) -> Bool {
351-
guard condition.shouldRetry(request: request, result: result, configs: configs) else {
372+
func needRetry(_ error: Error?) -> Bool {
373+
guard condition.shouldRetry(request: request, response: response, error: error, configs: configs) else {
352374
return false
353375
}
354376
if let limit {
@@ -359,7 +381,7 @@ private struct retryMiddleware: HTTPClientMiddleware {
359381

360382
func retry() async throws -> (T, HTTPResponse) {
361383
if count > 0 {
362-
let interval = UInt64(max(retryAfterHeader, interval(count - 1)) * 1_000_000_000)
384+
let interval = UInt64(max(retryAfterHeader, interval(count - 1, response)) * 1_000_000_000)
363385
if interval > 0 {
364386
if let response, let hash = backoffPolicy.scopeHash(request), backoffPolicy.isGlobalBackoff(request, response.status) {
365387
_ = try await withThrowingSynchronizedAccess(id: hash) {
@@ -372,8 +394,14 @@ private struct retryMiddleware: HTTPClientMiddleware {
372394
}
373395
}
374396
count += 1
375-
let (result, rsp) = try await extractResponseEvenFailed {
376-
try await next(request, configs)
397+
let (result, rsp): (Result<(T, HTTPResponse), Error>, HTTPResponse)
398+
do {
399+
(result, rsp) = try await extractResponseEvenFailed {
400+
try await next(request, configs)
401+
}
402+
} catch {
403+
response = nil
404+
throw error
377405
}
378406
response = rsp
379407
return try result.get()
@@ -382,17 +410,18 @@ private struct retryMiddleware: HTTPClientMiddleware {
382410
while true {
383411
do {
384412
let (data, httpResponse) = try await retry()
385-
// 429 and 503 may include Retry-After header by RFC 7231
386-
if [429, 503].contains(httpResponse.status.code) {
413+
if configs.retryAfterHeaderStatusCodes.contains(httpResponse.status) {
387414
retryAfterHeader = httpResponse.headerFields[.retryAfter].flatMap {
388415
decodeRetryAfterHeader($0, formatter: configs.retryAfterHeaderDateFormatter)
389416
} ?? 0
390417
}
391-
if !needRetry(.success(httpResponse)) {
418+
if !needRetry(nil) {
392419
return (data, httpResponse)
393420
}
394-
} catch {
395-
if !needRetry(.failure(error)) {
421+
} catch is CancellationError {
422+
throw CancellationError()
423+
} catch {
424+
if !needRetry(error) {
396425
throw error
397426
}
398427
}
@@ -401,18 +430,36 @@ private struct retryMiddleware: HTTPClientMiddleware {
401430
}
402431
}
403432

404-
@inline(__always)
405-
private func jitterNs(
406-
_ base: UInt64,
407-
fraction: ClosedRange<Double> = 0.1...0.2,
408-
minNs: UInt64 = 5_000_000, // 5 ms
409-
maxNs: UInt64 = 1_000_000_000
410-
) // 1 s
411-
-> UInt64 {
412-
guard base > 0 else { return 0 }
413-
let p = Double.random(in: fraction)
414-
let raw = UInt64(Double(base) * p)
415-
return min(max(raw, minNs), maxNs)
433+
/// Configuration for jitter applied to retry intervals.
434+
public struct RetryJitterConfigs: Hashable {
435+
436+
/// The fraction range of the base interval to use for jitter.
437+
public var fraction: ClosedRange<Double>
438+
439+
/// The minimum jitter in nanoseconds.
440+
public var minNs: UInt64
441+
442+
/// The maximum jitter in nanoseconds.
443+
public var maxNs: UInt64
444+
445+
public init(
446+
fraction: ClosedRange<Double> = 0.1...0.2,
447+
minNs: UInt64 = 5_000_000, // 5 ms
448+
maxNs: UInt64 = 1_000_000_000 // 1 s
449+
) {
450+
self.fraction = fraction
451+
self.minNs = minNs
452+
self.maxNs = maxNs
453+
}
454+
455+
/// Applies jitter to the given interval.
456+
@inline(__always)
457+
public func delay(for interval: UInt64) -> UInt64 {
458+
guard interval > 0 else { return 0 }
459+
let p = Double.random(in: fraction)
460+
let raw = UInt64(Double(interval) * p)
461+
return min(max(raw, minNs), maxNs)
462+
}
416463
}
417464

418465
private func decodeRetryAfterHeader(_ value: String, formatter: DateFormatter) -> TimeInterval? {

0 commit comments

Comments
 (0)