chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user