Skip to content

Commit fff7214

Browse files
committed
update retry modifier
1 parent b4eace8 commit fff7214

File tree

3 files changed

+133
-7
lines changed

3 files changed

+133
-7
lines changed

Sources/SwiftAPIClient/Modifiers/RateLimitModifier.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,35 @@ public extension APIClient {
88
/// - id: The identifier to use for rate limiting. Default to the base URL of the request.
99
/// - interval: The interval to wait before repeating the request. Default to 30 seconds.
1010
/// - statusCodes: The set of status codes that indicate a rate limit exceeded. Default to `[429]`.
11+
/// - methods: The set of HTTP methods to retry. If `nil`, all methods are retried. Default to `nil`.
1112
/// - maxRepeatCount: The maximum number of times the request can be repeated. Default to 3.
1213
func waitIfRateLimitExceeded<ID: Hashable>(
1314
id: @escaping (HTTPRequestComponents) -> ID,
1415
interval: TimeInterval = 30,
1516
statusCodes: Set<HTTPResponse.Status> = [.tooManyRequests],
17+
methods: Set<HTTPRequest.Method>? = nil,
1618
maxRepeatCount: Int = 3
1719
) -> Self {
18-
httpClientMiddleware(RateLimitMiddleware(id: id, interval: interval, statusCodes: statusCodes, maxCount: maxRepeatCount))
20+
httpClientMiddleware(RateLimitMiddleware(id: id, interval: interval, statusCodes: statusCodes, methods: methods, maxCount: maxRepeatCount))
1921
}
2022

2123
/// When the rate limit is exceeded, the request will be repeated after the specified interval and all requests with the same base URL will be suspended.
2224
/// - Parameters:
2325
/// - interval: The interval to wait before repeating the request. Default to 30 seconds.
2426
/// - statusCodes: The set of status codes that indicate a rate limit exceeded. Default to `[429]`.
27+
/// - methods: The set of HTTP methods to retry. If `nil`, all methods are retried. Default to `nil`.
2528
/// - maxRepeatCount: The maximum number of times the request can be repeated. Default to 3.
2629
func waitIfRateLimitExceeded(
2730
interval: TimeInterval = 30,
2831
statusCodes: Set<HTTPResponse.Status> = [.tooManyRequests],
32+
methods: Set<HTTPRequest.Method>? = nil,
2933
maxRepeatCount: Int = 3
3034
) -> Self {
3135
waitIfRateLimitExceeded(
3236
id: { $0.url?.host ?? UUID().uuidString },
3337
interval: interval,
3438
statusCodes: statusCodes,
39+
methods: methods,
3540
maxRepeatCount: maxRepeatCount
3641
)
3742
}
@@ -42,13 +47,19 @@ private struct RateLimitMiddleware<ID: Hashable>: HTTPClientMiddleware {
4247
let id: (HTTPRequestComponents) -> ID
4348
let interval: TimeInterval
4449
let statusCodes: Set<HTTPResponse.Status>
50+
let methods: Set<HTTPRequest.Method>?
4551
let maxCount: Int
4652

4753
func execute<T>(
4854
request: HTTPRequestComponents,
4955
configs: APIClient.Configs,
5056
next: @escaping Next<T>
5157
) async throws -> (T, HTTPResponse) {
58+
if let methods {
59+
guard methods.contains(request.method) else {
60+
return try await next(request, configs)
61+
}
62+
}
5263
let id = id(request)
5364
await waitForSynchronizedAccess(id: id, of: Void.self)
5465
var (res, status) = try await extractStatusCodeEvenFailed {

Sources/SwiftAPIClient/Modifiers/RetryModifier.swift

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,134 @@ import FoundationNetworking
55
import HTTPTypes
66

77
public extension APIClient {
8-
8+
9+
/// Retries the request if it fails.
10+
/// - Parameters:
11+
/// - limit: The maximum number of retries. If `nil`, it will retry indefinitely.
12+
/// - interval: The time interval to wait before the next retry. If not provided.
13+
/// - 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. If not provided, it defaults to retrying safe methods (like GET) on error status codes or network errors.
14+
/// - Note: Like any modifier, this is order dependent. It takes in account only error from previous modifiers but not the following ones.
15+
func retry(
16+
when condition: RetryRequestCondition = .requestFailed,
17+
limit: Int?,
18+
interval: TimeInterval
19+
) -> APIClient {
20+
retry(when: condition,limit: limit, interval: { _ in interval })
21+
}
22+
923
/// Retries the request if it fails.
10-
func retry(limit: Int?) -> APIClient {
11-
httpClientMiddleware(RetryMiddleware(limit: limit))
24+
/// - Parameters:
25+
/// - limit: The maximum number of retries. If `nil`, it will retry indefinitely.
26+
/// - interval: A closure that takes the current retry count (starting from 0) and returns the time interval to wait before the next retry. If not provided, it defaults to 0 seconds.
27+
/// - 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. If not provided, it defaults to retrying safe methods (like GET) on error status codes or network errors.
28+
/// - Note: Like any modifier, this is order dependent. It takes in account only error from previous modifiers but not the following ones.
29+
func retry(
30+
when condition: RetryRequestCondition = .requestFailed,
31+
limit: Int?,
32+
interval: @escaping (Int) -> TimeInterval = { _ in 0 }
33+
) -> APIClient {
34+
httpClientMiddleware(RetryMiddleware(limit: limit, interval: interval, condition: condition))
1235
}
1336
}
1437

38+
/// A condition that determines whether a request should be retried based on the request, the result of the request, and the client configurations.
39+
public struct RetryRequestCondition {
40+
41+
private let condition: (HTTPRequestComponents, Result<HTTPResponse, Error>, APIClient.Configs) -> Bool
42+
43+
/// Initializes a new `RetryRequestCondition` with a custom condition closure.
44+
/// - Parameters:
45+
/// - 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.
46+
public init(
47+
_ condition: @escaping (_ request: HTTPRequestComponents, _ result: Result<HTTPResponse, Error>, _ configs: APIClient.Configs) -> Bool
48+
) {
49+
self.condition = condition
50+
}
51+
52+
/// Determines whether the request should be retried based on the provided condition.
53+
/// - Parameters:
54+
/// - request: The original HTTP request components.
55+
/// - result: The result of the HTTP request, which can be either a successful response or an error.
56+
/// - configs: The configurations of the API client.
57+
public func shouldRetry(
58+
request: HTTPRequestComponents,
59+
result: Result<HTTPResponse, Error>,
60+
configs: APIClient.Configs
61+
) -> Bool {
62+
condition(request, result, configs)
63+
}
64+
65+
/// Combines two `RetryRequestCondition` instances using a logical AND operation.
66+
/// The resulting condition will only return `true` if both conditions return `true`.
67+
/// - Parameter other: Another `RetryRequestCondition` to combine with.
68+
public func and(_ other: RetryRequestCondition) -> RetryRequestCondition {
69+
RetryRequestCondition { request, result, configs in
70+
self.shouldRetry(request: request, result: result, configs: configs)
71+
&& other.shouldRetry(request: request, result: result, configs: configs)
72+
}
73+
}
74+
75+
/// Combines two `RetryRequestCondition` instances using a logical OR operation.
76+
/// The resulting condition will return `true` if either condition returns `true`.
77+
/// - Parameter other: Another `RetryRequestCondition` to combine with.
78+
public func or(_ other: RetryRequestCondition) -> RetryRequestCondition {
79+
RetryRequestCondition { request, result, configs in
80+
self.shouldRetry(request: request, result: result, configs: configs)
81+
|| other.shouldRetry(request: request, result: result, configs: configs)
82+
}
83+
}
84+
85+
/// A predefined `RetryRequestCondition` that retries safe HTTP methods (like GET) when the request fails due to error status codes or network errors.
86+
public static let requestFailed = RetryRequestCondition { request, result, _ in
87+
guard request.method.isSafe else {
88+
return false
89+
}
90+
switch result {
91+
case let .success(response):
92+
return response.status.kind.isError
93+
case let .failure(error):
94+
return !(error is CancellationError)
95+
}
96+
}
97+
98+
/// A predefined `RetryRequestCondition` that retries the request when the response status code is `429 Too Many Requests`.
99+
public static let rateLimitExceeded = RetryRequestCondition.requestFailed.and(
100+
RetryRequestCondition { _, response, _ in
101+
if case let .success(response) = response {
102+
return response.status == .tooManyRequests
103+
}
104+
return true
105+
}
106+
)
107+
}
108+
15109
private struct RetryMiddleware: HTTPClientMiddleware {
16110

17111
let limit: Int?
112+
let interval: (Int) -> TimeInterval
113+
let condition: RetryRequestCondition
18114

19115
func execute<T>(
20116
request: HTTPRequestComponents,
21117
configs: APIClient.Configs,
22118
next: @escaping Next<T>
23119
) async throws -> (T, HTTPResponse) {
24120
var count = 0
25-
func needRetry() -> Bool {
121+
func needRetry(_ result: Result<HTTPResponse, Error>) -> Bool {
122+
guard condition.shouldRetry(request: request, result: result, configs: configs) else {
123+
return false
124+
}
26125
if let limit {
27126
return count <= limit
28127
}
29128
return true
30129
}
31130

32131
func retry() async throws -> (T, HTTPResponse) {
132+
let interval = UInt64(interval(count) * 1_000_000_000)
133+
if interval > 0 {
134+
try await Task.sleep(nanoseconds: interval)
135+
}
33136
count += 1
34137
return try await next(request, configs)
35138
}
@@ -39,12 +142,12 @@ private struct RetryMiddleware: HTTPClientMiddleware {
39142
do {
40143
(data, response) = try await retry()
41144
} catch {
42-
if needRetry() {
145+
if needRetry(.failure(error)) {
43146
return try await retry()
44147
}
45148
throw error
46149
}
47-
if response.status.kind.isError, needRetry() {
150+
if needRetry(.success(response)) {
48151
return try await retry()
49152
}
50153
return (data, response)

Sources/SwiftAPIClient/Utils/Status+Ext.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,15 @@ extension HTTPResponse.Status.Kind {
77
self == .clientError || self == .serverError || self == .invalid
88
}
99
}
10+
11+
extension HTTPRequest.Method {
12+
13+
var isSafe: Bool {
14+
switch self {
15+
case .get, .head, .options, .trace:
16+
return true
17+
default:
18+
return false
19+
}
20+
}
21+
}

0 commit comments

Comments
 (0)