Files
steipete--codexbar/Sources/CodexBarCore/ProviderHTTPClient.swift
T
2026-07-13 12:22:33 +08:00

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
}
}
}