@@ -3,43 +3,82 @@ import Foundation
33import FoundationNetworking
44#endif
55import HTTPTypes
6+ import Logging
7+
8+ public extension APIClient . Configs {
9+
10+ /// The condition used to determine whether a request should be retried.
11+ /// - Note: This configuration works only if you use the `retry` modifier.
12+ var retryCondition : RetryRequestCondition {
13+ get { self [ \. retryCondition] ?? . default }
14+ set { self [ \. retryCondition] = newValue }
15+ }
16+
17+ /// The maximum number of retries for a request. If `nil`, it will retry indefinitely.
18+ /// - Note: This configuration works only if you use the `retry` modifier.
19+ var retryLimit : Int ? {
20+ get { self [ \. retryLimit] }
21+ set { self [ \. retryLimit] = newValue }
22+ }
23+
24+ /// 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.
25+ /// - Note: This configuration works only if you use the `retry` modifier.
26+ var retryInterval : ( Int ) -> TimeInterval {
27+ get { self [ \. retryInterval] ?? { _ in 0 } }
28+ set { self [ \. retryInterval] = newValue }
29+ }
30+
31+ /// The date formatter used to parse the `Retry-After` header when it contains a date. By default, it uses the RFC 1123 format.
32+ /// - Tips: `DateFormatter` creation is expensive, so if you need a custom format, create the formatter once and reuse it.
33+ var retryAfterHeaderDateFormatter : DateFormatter {
34+ get { self [ \. retryAfterHeaderDateFormatter] ?? defaultRetryAfterHeaderDateFormatter }
35+ set { self [ \. retryAfterHeaderDateFormatter] = newValue }
36+ }
37+ }
638
739public extension APIClient {
840
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. Defaults to 0 seconds.
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.
14- /// If not provided, it defaults to retrying safe methods (like GET) on error status codes or network errors.
15- /// - retryKey: A closure that takes the request and returns a key used to group retries. Requests with the same key will share retry decisions.
16- /// For example, if a rate limit error occurs for one request, all other requests with the same key (such as the same host) will be delayed. Defaults to `nil`, meaning no grouping.
17- /// - Note: Like any modifier, this is order dependent. It takes in account only error from previous modifiers but not the following ones.
18- func retry(
19- retryKey: ( ( HTTPRequestComponents ) -> AnyHashable ) ? = nil ,
20- when condition: RetryRequestCondition = . requestFailed,
21- limit: Int ? ,
22- interval: TimeInterval = 0
23- ) -> APIClient {
24- retry ( when: condition, limit: limit, interval: { _ in interval } , retryKey: retryKey)
41+ /// Sets the condition used to determine whether a request should be retried.
42+ /// - Parameter condition: A closure that takes the request, the result of the request,
43+ /// - Note: This configuration works only if you use the `retry` modifier.
44+ func retryCondition( _ condition: RetryRequestCondition ) -> APIClient {
45+ configs ( \. retryCondition, condition)
46+ }
47+
48+ /// Sets the maximum number of retries for a request. If `nil`, it will retry indefinitely.
49+ /// - Parameter limit: The maximum number of retries.
50+ /// - Note: This configuration works only if you use the `retry` modifier.
51+ func retryLimit( _ limit: Int ? ) -> APIClient {
52+ configs ( \. retryLimit, limit)
2553 }
2654
55+ /// 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.
56+ /// - Parameter interval: A closure that takes the current retry count (starting from 0
57+ /// - Note: This configuration works only if you use the `retry` modifier.
58+ func retryInterval( _ interval: @escaping ( Int ) -> TimeInterval ) -> APIClient {
59+ configs ( \. retryInterval, interval)
60+ }
61+
62+ /// Sets a fixed interval between retries.
63+ /// - Parameter interval: The time interval to wait before the next retry.
64+ /// - Note: This configuration works only if you use the `retry` modifier.
65+ func retryInterval( _ interval: TimeInterval ) -> APIClient {
66+ retryInterval { _ in interval }
67+ }
68+ }
69+
70+ public extension APIClient {
71+
2772 /// Retries the request if it fails.
2873 /// - Parameters:
29- /// - limit: The maximum number of retries. If `nil`, it will retry indefinitely.
30- /// - 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.
31- /// - 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.
32- /// If not provided, it defaults to retrying safe methods (like GET) on error status codes or network errors.
3374 /// - retryKey: A closure that takes the request and returns a key used to group retries. Requests with the same key will share retry decisions.
3475 /// For example, if a rate limit error occurs for one request, all other requests with the same key (such as the same host) will be delayed. Defaults to `nil`, meaning no grouping.
3576 /// - Note: Like any modifier, this is order dependent. It takes in account only error from previous modifiers but not the following ones.
77+ /// - Tip: You can customize the retry behavior by setting the `retryCondition`, `retryLimit`, and `retryInterval` configurations. Also, there is `\.retryAfterHeaderDateFormatter` configuration to customize parsing of `Retry-After` header.
3678 func retry(
37- when condition: RetryRequestCondition = . requestFailed,
38- limit: Int ? ,
39- interval: @escaping ( Int ) -> TimeInterval ,
4079 retryKey: ( ( HTTPRequestComponents ) -> AnyHashable ) ? = nil
4180 ) -> APIClient {
42- httpClientMiddleware ( RetryMiddleware ( retryKey: retryKey, limit : limit , interval : interval , condition : condition ) )
81+ httpClientMiddleware ( RetryMiddleware ( retryKey: retryKey) )
4382 }
4483}
4584
@@ -80,6 +119,52 @@ public struct RetryRequestCondition {
80119 }
81120 }
82121
122+ /// Combines multiple `RetryRequestCondition` instances using a logical AND operation.
123+ /// The resulting condition will only return `true` if all conditions return `true`.
124+ /// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
125+ /// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
126+ public static func and( _ conditions: RetryRequestCondition ... ) -> RetryRequestCondition {
127+ and ( conditions)
128+ }
129+
130+ /// Combines multiple `RetryRequestCondition` instances using a logical AND operation.
131+ /// The resulting condition will only return `true` if all conditions return `true`.
132+ /// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
133+ /// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
134+ public static func and( _ conditions: [ RetryRequestCondition ] ) -> RetryRequestCondition {
135+ RetryRequestCondition { request, result, configs in
136+ for condition in conditions {
137+ if !condition. shouldRetry ( request: request, result: result, configs: configs) {
138+ return false
139+ }
140+ }
141+ return true
142+ }
143+ }
144+
145+ /// Combines multiple `RetryRequestCondition` instances using a logical OR operation.
146+ /// The resulting condition will return `true` if any of the conditions return `true`.
147+ /// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
148+ /// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
149+ public static func or( _ conditions: RetryRequestCondition ... ) -> RetryRequestCondition {
150+ or ( conditions)
151+ }
152+
153+ /// Combines multiple `RetryRequestCondition` instances using a logical OR operation.
154+ /// The resulting condition will return `true` if any of the conditions return `true`.
155+ /// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
156+ /// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
157+ public static func or( _ conditions: [ RetryRequestCondition ] ) -> RetryRequestCondition {
158+ RetryRequestCondition { request, result, configs in
159+ for condition in conditions {
160+ if condition. shouldRetry ( request: request, result: result, configs: configs) {
161+ return true
162+ }
163+ }
164+ return false
165+ }
166+ }
167+
83168 /// Combines two `RetryRequestCondition` instances using a logical OR operation.
84169 /// The resulting condition will return `true` if either condition returns `true`.
85170 /// - Parameter other: Another `RetryRequestCondition` to combine with.
@@ -90,21 +175,42 @@ public struct RetryRequestCondition {
90175 }
91176 }
92177
178+ /// The default `RetryRequestCondition` that retries safe HTTP methods (like GET) when the request fails due to error status codes or network errors.
179+ /// This condition combines the following:
180+ /// - `requestMethodIsSafe`: Ensures that only safe HTTP methods are retried.
181+ /// - `requestFailed`: Retries when the request fails due to network errors.
182+ /// - `retryableStatusCode`: Retries when the response status code indicates a transient failure.
183+ /// This default behavior is suitable for most scenarios where idempotent requests should be retried on failure.
184+ public static var `default` : RetryRequestCondition {
185+ . and(
186+ . requestMethodIsSafe,
187+ . or(
188+ . requestFailed,
189+ . retryableStatusCode
190+ )
191+ )
192+ }
193+
93194 /// A `RetryRequestCondition` that retries safe HTTP methods (like GET) when the request fails due to error status codes or network errors.
94195 public static let requestFailed = RetryRequestCondition { request, result, _ in
95- guard request. method. isSafe else {
96- return false
97- }
98196 switch result {
99197 case let . success( response) :
100- return response . status . kind . isError
198+ return false
101199 case let . failure( error) :
102200 return !( error is CancellationError )
103201 }
104202 }
203+
204+ /// A `RetryRequestCondition` that retries the request when the HTTP method is considered safe (e.g., GET, HEAD, OPTIONS).
205+ public static let requestMethodIsSafe = RetryRequestCondition { request, _, _ in
206+ request. method. isSafe
207+ }
208+
209+ /// A `RetryRequestCondition` that retries the request when the response status code indicates a failure that is typically transient.
210+ public static let retryableStatusCode = RetryRequestCondition . statusCodes ( [ 408 , 421 , 429 , 500 , 502 , 503 , 504 , 509 ] )
105211
106212 /// A `RetryRequestCondition` that retries the request when the response status code is `429 Too Many Requests`.
107- public static let rateLimitExceeded = RetryRequestCondition . requestFailed . and ( . statusCodes( . tooManyRequests) )
213+ public static let rateLimitExceeded = RetryRequestCondition . statusCodes ( . tooManyRequests)
108214
109215 /// A `RetryRequestCondition` that retries requests with defined HTTP methods.
110216 public static func methods( _ methods: HTTPRequest . Method ... ) -> RetryRequestCondition {
@@ -137,15 +243,15 @@ public struct RetryRequestCondition {
137243private struct RetryMiddleware : HTTPClientMiddleware {
138244
139245 let retryKey : ( ( HTTPRequestComponents ) -> AnyHashable ) ?
140- let limit : Int ?
141- let interval : ( Int ) -> TimeInterval
142- let condition : RetryRequestCondition
143246
144247 func execute< T> (
145248 request: HTTPRequestComponents ,
146249 configs: APIClient . Configs ,
147250 next: @escaping Next < T >
148251 ) async throws -> ( T , HTTPResponse ) {
252+ let condition = configs. retryCondition
253+ let limit = configs. retryLimit
254+ let interval = configs. retryInterval
149255 if let retryKey {
150256 await waitForSynchronizedAccess ( id: retryKey ( request) , of: Void . self)
151257 }
@@ -183,7 +289,9 @@ private struct RetryMiddleware: HTTPClientMiddleware {
183289 while true {
184290 do {
185291 let ( data, response) = try await retry ( )
186- response. headerFields [ . retryAfter]
292+ retryAfterHeader = response. headerFields [ . retryAfter] . flatMap {
293+ decodeRetryAfterHeader ( $0, formatter: configs. retryAfterHeaderDateFormatter)
294+ } ?? 0
187295 if !needRetry( . success( response) ) {
188296 return ( data, response)
189297 }
@@ -197,4 +305,29 @@ private struct RetryMiddleware: HTTPClientMiddleware {
197305 }
198306}
199307
308+ private func decodeRetryAfterHeader( _ value: String , formatter: DateFormatter ) -> TimeInterval ? {
309+ // seconds
310+ if let seconds = TimeInterval ( value) {
311+ return seconds
312+ }
313+
314+ // RFC 1123 date
315+ if let date = formatter. date ( from: value) {
316+ let delta = date. timeIntervalSinceNow
317+ return delta > 0 ? delta : 0
318+ }
319+
320+ Logger ( label: " SwiftAPIClient " ) . warning ( " Failed to parse Retry-After header: ' \( value) ' using ' \( formatter. dateFormat ?? " nil " ) ' format. " )
321+ return nil
322+ }
323+
324+ private let defaultRetryAfterHeaderDateFormatter : DateFormatter = {
325+ let formatter = DateFormatter ( )
326+ // RFC 1123
327+ formatter. locale = Locale ( identifier: " en_US_POSIX " )
328+ formatter. timeZone = TimeZone ( secondsFromGMT: 0 )
329+ formatter. dateFormat = " EEE',' dd MMM yyyy HH:mm:ss zzz "
330+ return formatter
331+ } ( )
332+
200333private struct ImpossibleError : Error { }
0 commit comments