import Foundation /// Why a native request failed, typed so that a caller can tell "you are /// offline" from "your session has ended". Both used to arrive as the same /// English sentence, so an app holding unsent edits could only tell its user /// to sign out, which would have thrown those edits away. public enum NativeClientError: LocalizedError, Sendable, Equatable { /// Local validation text, such as an invalid server address. case message(String) /// The request never reached the server, or its answer never came back. /// See `offlineCodes` for exactly which transport failures count. case offline(URLError.Code) /// 401, and 303: `NoRedirects` surfaces the redirect to /signin as a status. case unauthorized case forbidden case notFound /// 409. The body is kept so the app can read the server's side of it. case conflict(Data) /// 413. case tooLarge /// 429, and a 503 that carries Retry-After. case rateLimited(retryAfter: TimeInterval?) /// Any other status outside 2xx. case rejected(status: Int, body: Data) /// 5xx. case server(status: Int) case invalidResponse /// A transport failure that does not mean "offline", such as a TLS /// failure. Reporting a bad certificate as offline would hide a real fault /// behind a banner that tells the person to wait for a connection. case transport(URLError.Code) public enum RetryClass: Sendable, Equatable { /// Wait for connectivity, then retry. case offline /// Retry with backoff. case transient /// Retrying cannot help until the person signs in again. case auth /// Retrying the same request cannot succeed. case permanent /// The app decides; the server kept its own version. case conflict } public var retryClass: RetryClass { switch self { case .offline: return .offline case .unauthorized: return .auth case .conflict: return .conflict case .rateLimited, .server, .transport: return .transient case .message, .forbidden, .notFound, .tooLarge, .rejected, .invalidResponse: return .permanent } } /// Today's strings, so existing callers that show `localizedDescription` /// read exactly as they did before the cases were typed. public var errorDescription: String? { switch self { case .message(let value): return value case .offline(let code), .transport(let code): return URLError(code).localizedDescription case .unauthorized: return "Your session has ended. Sign out and sign in again." case .notFound: return "This server does not support this native feature yet." case .forbidden: return Self.unconfirmed(403) case .conflict: return Self.unconfirmed(409) case .tooLarge: return Self.unconfirmed(413) case .rateLimited: return "The server is busy. Your changes have not been confirmed." case .rejected(let status, _), .server(let status): return Self.unconfirmed(status) case .invalidResponse: return "Invalid server response." } } private static func unconfirmed(_ status: Int) -> String { "The request could not be completed (\(status)). Your changes have not been confirmed." } /// Transport failures that mean the network, not the server, is missing. static let offlineCodes: Set = [ .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed, .timedOut, .dataNotAllowed, .internationalRoamingOff, ] /// Maps a URLSession failure to a typed case. A cancelled task surfaces as /// `CancellationError`, like every other cancelled Swift task: URLSession's /// own -999 would otherwise reach callers that match cancellation by type /// and read as a failure. Errors that are not URLErrors pass through untouched. static func classify(_ error: Error) -> Error { guard let failure = error as? URLError else { return error } if failure.code == .cancelled { return CancellationError() } return offlineCodes.contains(failure.code) ? NativeClientError.offline(failure.code) : NativeClientError.transport(failure.code) } /// The typed failure for an HTTP status, or nil for 2xx. static func forStatus(_ status: Int, body: Data, headers: [String: String], now: Date = Date()) -> NativeClientError? { switch status { case 200..<300: return nil case 401, 303: return .unauthorized case 403: return .forbidden case 404: return .notFound case 409: return .conflict(body) case 413: return .tooLarge case 429: return .rateLimited(retryAfter: retryAfter(headers["retry-after"], now: now)) case 503: if let header = headers["retry-after"] { return .rateLimited(retryAfter: retryAfter(header, now: now)) } return .server(status: status) case 500..<600: return .server(status: status) default: return .rejected(status: status, body: body) } } /// Retry-After is either delay-seconds or an HTTP-date (RFC 9110 ยง10.2.3). static func retryAfter(_ header: String?, now: Date) -> TimeInterval? { guard let text = header?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil } if let seconds = Int(text), seconds >= 0 { return TimeInterval(seconds) } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(identifier: "GMT") formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" guard let date = formatter.date(from: text) else { return nil } return max(0, date.timeIntervalSince(now)) } }