import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public protocol ProviderHTTPTransport: Sendable { func data(for request: URLRequest) async throws -> (Data, URLResponse) } #if !os(Linux) extension URLSession: ProviderHTTPTransport {} #endif extension URLSession { public func response(for request: URLRequest) async throws -> ProviderHTTPResponse { let (data, response) = try await self.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } return ProviderHTTPResponse(data: data, response: httpResponse) } } public struct ProviderHTTPResponse: Sendable { public let data: Data public let response: HTTPURLResponse public init(data: Data, response: HTTPURLResponse) { self.data = data self.response = response } public var statusCode: Int { self.response.statusCode } } public struct ProviderHTTPRetryPolicy: Sendable { public let maxRetries: Int public let retryableStatusCodes: Set public let retryableURLErrorCodes: Set public let retryableMethods: Set public let baseDelaySeconds: TimeInterval public let maxDelaySeconds: TimeInterval public init( maxRetries: Int, retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], retryableURLErrorCodes: Set = [ .timedOut, .networkConnectionLost, .cannotConnectToHost, .cannotFindHost, .dnsLookupFailed, ], retryableMethods: Set = ["GET", "HEAD", "OPTIONS"], baseDelaySeconds: TimeInterval = 1, maxDelaySeconds: TimeInterval = 10) { self.maxRetries = max(0, maxRetries) self.retryableStatusCodes = retryableStatusCodes self.retryableURLErrorCodes = retryableURLErrorCodes self.retryableMethods = retryableMethods self.baseDelaySeconds = max(0, baseDelaySeconds) self.maxDelaySeconds = max(0, maxDelaySeconds) } public static let disabled = ProviderHTTPRetryPolicy( maxRetries: 0, retryableStatusCodes: [], retryableURLErrorCodes: [], baseDelaySeconds: 0, maxDelaySeconds: 0) public static let transientIdempotent = ProviderHTTPRetryPolicy(maxRetries: 1) func shouldRetry(request: URLRequest, attempt: Int, statusCode: Int) -> Bool { self.canRetry(request: request, attempt: attempt) && self.retryableStatusCodes.contains(statusCode) } func shouldRetry(request: URLRequest, attempt: Int, error: Error) -> Bool { guard self.canRetry(request: request, attempt: attempt) else { return false } guard let urlError = error as? URLError else { return false } return self.retryableURLErrorCodes.contains(urlError.code) } func delaySeconds(attempt: Int, response: HTTPURLResponse?) -> TimeInterval { if let retryAfter = response?.value(forHTTPHeaderField: "Retry-After"), let seconds = TimeInterval(retryAfter.trimmingCharacters(in: .whitespacesAndNewlines)), seconds >= 0 { return min(seconds, self.maxDelaySeconds) } guard self.baseDelaySeconds > 0 else { return 0 } let multiplier = pow(2, Double(max(0, attempt))) return min(self.baseDelaySeconds * multiplier, self.maxDelaySeconds) } private func canRetry(request: URLRequest, attempt: Int) -> Bool { guard attempt < self.maxRetries else { return false } let method = request.httpMethod?.uppercased() ?? "GET" return self.retryableMethods.contains(method) } } public struct ProviderHTTPTransportHandler: ProviderHTTPTransport { private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) public init(_ handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) { self.handler = handler } public func data(for request: URLRequest) async throws -> (Data, URLResponse) { try await self.handler(request) } } extension ProviderHTTPTransport { public func response(for request: URLRequest) async throws -> ProviderHTTPResponse { try await self.response(for: request, retryPolicy: .disabled) } public func response( for request: URLRequest, retryPolicy: ProviderHTTPRetryPolicy) async throws -> ProviderHTTPResponse { var attempt = 0 while true { do { let (data, response) = try await self.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } let providerResponse = ProviderHTTPResponse(data: data, response: httpResponse) guard retryPolicy.shouldRetry( request: request, attempt: attempt, statusCode: providerResponse.statusCode) else { return providerResponse } try await Self.sleepBeforeRetry(policy: retryPolicy, attempt: attempt, response: httpResponse) attempt += 1 } catch { guard retryPolicy.shouldRetry(request: request, attempt: attempt, error: error) else { throw error } try await Self.sleepBeforeRetry(policy: retryPolicy, attempt: attempt, response: nil) attempt += 1 } } } private static func sleepBeforeRetry( policy: ProviderHTTPRetryPolicy, attempt: Int, response: HTTPURLResponse?) async throws { let delay = policy.delaySeconds(attempt: attempt, response: response) guard delay > 0 else { return } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendable { public static let shared = ProviderHTTPClient(session: ProviderHTTPClient.sharedSession()) private let session: URLSession public init(session: URLSession? = nil) { self.session = session ?? Self.redirectGuardedSession() } static func defaultConfiguration() -> URLSessionConfiguration { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 30 configuration.timeoutIntervalForResource = 90 #if !os(Linux) configuration.waitsForConnectivity = false #endif return configuration } private static func sharedSession() -> URLSession { if self.isRunningTests { // XCTest URLProtocol.registerClass stubs only intercept URLSession.shared on macOS. return .shared } return self.redirectGuardedSession() } static func redirectGuardedSession( configuration: URLSessionConfiguration = ProviderHTTPClient.defaultConfiguration()) -> URLSession { URLSession( configuration: configuration, delegate: ProviderHTTPRedirectGuardDelegate(), delegateQueue: nil) } private static var isRunningTests: Bool { let environment = ProcessInfo.processInfo.environment if environment["XCTestConfigurationFilePath"] != nil || environment["XCTestBundlePath"] != nil { return true } if ProcessInfo.processInfo.processName.lowercased().contains("xctest") { return true } return CommandLine.arguments.contains { $0.lowercased().contains(".xctest") } } public func data(for request: URLRequest) async throws -> (Data, URLResponse) { try await self.session.data(for: request) } } final class ProviderHTTPRedirectGuardDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { func urlSession( _: URLSession, task: URLSessionTask, willPerformHTTPRedirection _: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { completionHandler(Self.guardedRedirectRequest(originalURL: task.originalRequest?.url, redirectRequest: request)) } static func guardedRedirectRequest(originalURL: URL?, redirectRequest request: URLRequest) -> URLRequest? { guard let originalURL, let redirectedURL = request.url else { return nil } guard originalURL.scheme?.caseInsensitiveCompare("https") == .orderedSame else { return nil } guard redirectedURL.scheme?.caseInsensitiveCompare("https") == .orderedSame else { return nil } guard self.isSameOrigin(originalURL, redirectedURL) else { return nil } return request } private static func isSameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && lhs.host?.lowercased() == rhs.host?.lowercased() && self.normalizedPort(lhs) == self.normalizedPort(rhs) } private static func normalizedPort(_ url: URL) -> Int? { if let port = url.port { return port } switch url.scheme?.lowercased() { case "http": return 80 case "https": return 443 default: return nil } } }