@@ -18,12 +18,40 @@ public extension URLSession {
1818 public var logger : Logger ? = defaultLogger
1919 public var logBody : Bool = logBody
2020
21+ // Retry policy
22+ public var maxRetries : Int = defaultMaxRetries
23+ /// Base delay (seconds) for exponential backoff: base * 2^(attempt-1)
24+ public var retryBaseDelay : TimeInterval = defaultRetryBaseDelay
25+ /// Upper bound for backoff delay (seconds)
26+ public var retryMaxDelay : TimeInterval = defaultRetryMaxDelay
27+ /// URLError codes that are considered retryable
28+ public var retryableURLErrorCodes : Set < URLError . Code > = defaultRetryableURLErrorCodes
29+ /// Whether to retry on HTTP 5xx responses
30+ public var retryOnServerErrors : Bool = defaultRetryOnServerErrors
31+
2132 public static var defaultTaskDelegate : URLSessionTaskDelegate ?
2233 public static var defaultLogger = Logger . networking
2334 public static var logBody : Bool = true
2435 /// Threshold for detect if response was from cache ( sec )
2536 public static var cacheDetectThreshold : TimeInterval = 0.05
2637
38+ // Default retry policy
39+ public static var defaultMaxRetries : Int = 2
40+ public static var defaultRetryBaseDelay : TimeInterval = 0.5
41+ public static var defaultRetryMaxDelay : TimeInterval = 6.0
42+ public static var defaultRetryableURLErrorCodes : Set < URLError . Code > = [
43+ . timedOut,
44+ . networkConnectionLost,
45+ . cannotConnectToHost,
46+ . cannotFindHost,
47+ . dnsLookupFailed,
48+ . notConnectedToInternet,
49+ . internationalRoamingOff,
50+ . callIsActive,
51+ . dataNotAllowed
52+ ]
53+ public static var defaultRetryOnServerErrors : Bool = true
54+
2755 public init ( ) { }
2856 }
2957
@@ -35,6 +63,7 @@ public extension URLSession {
3563 config: Config = . init( ) ,
3664 file: String = #file,
3765 function: String = #function,
66+ errorHandler: ErrorHandler ? = nil ,
3867 _ configurate: Configurate ? = nil
3968 ) async throws -> DataResponse {
4069
@@ -49,24 +78,71 @@ public extension URLSession {
4978
5079 let hasCachedResponse = configuration. urlCache? . cachedResponse ( for: request) != nil
5180
52- let start = CFAbsoluteTimeGetCurrent ( )
53- let ( data, urlResponse) = try await data ( for: request, delegate: config. taskDelegate)
54- let elapsed = CFAbsoluteTimeGetCurrent ( ) - start
55-
56- let fromCache = hasCachedResponse && elapsed < Config . cacheDetectThreshold
57- let respones = try urlResponse. httpResponse ( )
58-
59- let dataResponse = DataResponse ( request: request,
60- response: respones,
61- data: data,
62- fromCache: fromCache,
63- duration: elapsed)
64-
65- let respBodyLog = config. logBody ? " \n \( dataResponse. bodyString) " : " "
66- config. logger? . debug ( " 🛬 \( fromCache ? " (from cache) " : " " ) \( dataResponse. request. urlString) \( dataResponse. status) \n \( respBodyLog) \n 📄 \( file. lastPathComponent) " )
67-
68- return dataResponse
81+ var attempt = 0
82+ var lastError : Error ?
83+
84+ while true {
85+ attempt += 1
86+
87+ do {
88+ let start = CFAbsoluteTimeGetCurrent ( )
89+ let ( data, urlResponse) = try await data ( for: request, delegate: config. taskDelegate)
90+ let elapsed = CFAbsoluteTimeGetCurrent ( ) - start
91+
92+ let fromCache = hasCachedResponse && elapsed < Config . cacheDetectThreshold
93+ let respones = try urlResponse. httpResponse ( )
94+
95+ // Optionally retry on HTTP 5xx
96+ if config. retryOnServerErrors,
97+ ( 500 ... 599 ) . contains ( respones. status. code) ,
98+ attempt <= ( config. maxRetries + 1 ) {
99+
100+ config. logger? . debug ( " 🔁 HTTP \( respones. status. code) retry \( attempt) / \( config. maxRetries + 1 ) \( request. urlString) " )
101+ let delay = retryDelay ( attempt: attempt, base: config. retryBaseDelay, maxDelay: config. retryMaxDelay)
102+ try await Task . sleep ( nanoseconds: UInt64 ( delay * 1_000_000_000 ) )
103+ continue
104+ }
105+
106+ let dataResponse = DataResponse ( request: request,
107+ response: respones,
108+ data: data,
109+ fromCache: fromCache,
110+ duration: elapsed)
111+
112+ let respBodyLog = config. logBody ? " \n \( dataResponse. bodyString) " : " "
113+ config. logger? . debug ( " 🛬 \( fromCache ? " (from cache) " : " " ) \( dataResponse. request. urlString) \( dataResponse. status) \n \( respBodyLog) \n 📄 \( file. lastPathComponent) " )
114+
115+ return dataResponse
116+ } catch {
117+ lastError = error
118+
119+ // Never retry cancellations
120+ if let urlError = error as? URLError , urlError. code == . cancelled {
121+ throw error
122+ }
123+ if error is CancellationError {
124+ throw error
125+ }
126+
127+ let shouldRetry : Bool = {
128+ if let urlError = error as? URLError {
129+ return config. retryableURLErrorCodes. contains ( urlError. code)
130+ }
131+ return false
132+ } ( )
133+
134+ if shouldRetry, attempt <= config. maxRetries {
135+ config. logger? . debug ( " 🔁 Retry \( attempt) / \( config. maxRetries) \( request. urlString) error: \( error) " )
136+ let delay = retryDelay ( attempt: attempt, base: config. retryBaseDelay, maxDelay: config. retryMaxDelay)
137+ try await Task . sleep ( nanoseconds: UInt64 ( delay * 1_000_000_000 ) )
138+ continue
139+ }
140+
141+ throw error
142+ }
143+ }
69144 } catch {
145+ // Preserve existing cancellation logging
70146 if let urlError = error as? URLError , urlError. code == . cancelled {
71147 config. logger? . debug ( " 🛬 (canceled) \( request. urlString) " )
72148 throw error
@@ -75,12 +151,24 @@ public extension URLSession {
75151 throw error
76152 }
77153
154+ errorHandler ? ( error)
78155 config. logger? . error ( " 🛬 \( request. urlString) \n \( error) " )
79156 throw error
80157 }
81158 }
82159}
83160
161+ private func retryDelay( attempt: Int , base: TimeInterval , maxDelay: TimeInterval ) -> TimeInterval {
162+ // attempt is 1-based
163+ let exp = Swift . max ( 0 , attempt - 1 )
164+ let raw : TimeInterval = base * pow( 2.0 , Double ( exp) )
165+ let clamped : TimeInterval = Swift . min ( raw, maxDelay)
166+
167+ // Add a small jitter to avoid thundering herd
168+ let jitter : TimeInterval = Double . random ( in: 0.0 ... ( clamped * 0.2 ) )
169+ return clamped + jitter
170+ }
171+
84172extension Data {
85173 var json : String ? {
86174 guard
0 commit comments