254 lines
9.2 KiB
Swift
254 lines
9.2 KiB
Swift
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<Int>
|
|
public let retryableURLErrorCodes: Set<URLError.Code>
|
|
public let retryableMethods: Set<String>
|
|
public let baseDelaySeconds: TimeInterval
|
|
public let maxDelaySeconds: TimeInterval
|
|
|
|
public init(
|
|
maxRetries: Int,
|
|
retryableStatusCodes: Set<Int> = [408, 429, 500, 502, 503, 504],
|
|
retryableURLErrorCodes: Set<URLError.Code> = [
|
|
.timedOut,
|
|
.networkConnectionLost,
|
|
.cannotConnectToHost,
|
|
.cannotFindHost,
|
|
.dnsLookupFailed,
|
|
],
|
|
retryableMethods: Set<String> = ["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
|
|
}
|
|
}
|
|
}
|