Skip to content

Commit a3fe607

Browse files
committed
1.4.0
1 parent a105a81 commit a3fe607

6 files changed

Lines changed: 274 additions & 33 deletions

File tree

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ let package = Package(
1616
.library(name: "URLConfig", targets: ["URLConfig"])
1717
],
1818
dependencies: [
19-
.package(url: "https://github.com/apple/swift-http-types.git", from: "1.1.0")
19+
.package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1")
2020
],
2121
targets: [
2222
.target(

Sources/DataResponse.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,27 @@ public struct DataResponse {
3939

4040
public func decode<T: Decodable>(
4141
_ config: Config = .init(),
42-
_ type: T.Type = T.self) throws -> T {
43-
try config.decoder.decode(type, from: data)
44-
}
42+
_ type: T.Type = T.self,
43+
errorHandler: ErrorHandler? = nil) throws -> T {
44+
do {
45+
return try config.decoder.decode(type, from: data)
46+
} catch {
47+
errorHandler?(error)
48+
throw error
49+
}
50+
}
4551

4652
public func decode<T: Decodable>(
4753
decoder: JSONDecoder,
48-
_ type: T.Type = T.self
54+
_ type: T.Type = T.self,
55+
errorHandler: ErrorHandler? = nil
4956
) throws -> T {
50-
try decoder.decode(type, from: data)
57+
do {
58+
return try decoder.decode(type, from: data)
59+
} catch {
60+
errorHandler?(error)
61+
throw error
62+
}
5163
}
5264

5365
public var bodyString: String { data.json ?? "" }
@@ -56,3 +68,5 @@ public struct DataResponse {
5668
extension DataResponse: Error {
5769

5870
}
71+
72+
public typealias ErrorHandler = (Error) -> Void

Sources/URLRequest+.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ private extension String {
152152
}
153153
}
154154

155-
extension URLRequest {
155+
public extension URLRequest {
156156
mutating func setXWWWFormUrlencoded(_ parameters: [String: String]) {
157157
let bodyString = parameters.map { "\($0.key)=\($0.value)" }
158158
.joined(separator: "&")

Sources/URLSession+.swift

Lines changed: 105 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
84172
extension Data {
85173
var json: String? {
86174
guard

0 commit comments

Comments
 (0)