| 1 | import Foundation |
| 2 | |
| 3 | /// Why a native request failed, typed so that a caller can tell "you are |
| 4 | /// offline" from "your session has ended". Both used to arrive as the same |
| 5 | /// English sentence, so an app holding unsent edits could only tell its user |
| 6 | /// to sign out, which would have thrown those edits away. |
| 7 | public enum NativeClientError: LocalizedError, Sendable, Equatable { |
| 8 | /// Local validation text, such as an invalid server address. |
| 9 | case message(String) |
| 10 | /// The request never reached the server, or its answer never came back. |
| 11 | /// See `offlineCodes` for exactly which transport failures count. |
| 12 | case offline(URLError.Code) |
| 13 | /// 401, and 303: `NoRedirects` surfaces the redirect to /signin as a status. |
| 14 | case unauthorized |
| 15 | case forbidden |
| 16 | case notFound |
| 17 | /// 409. The body is kept so the app can read the server's side of it. |
| 18 | case conflict(Data) |
| 19 | /// 413. |
| 20 | case tooLarge |
| 21 | /// 429, and a 503 that carries Retry-After. |
| 22 | case rateLimited(retryAfter: TimeInterval?) |
| 23 | /// Any other status outside 2xx. |
| 24 | case rejected(status: Int, body: Data) |
| 25 | /// 5xx. |
| 26 | case server(status: Int) |
| 27 | case invalidResponse |
| 28 | /// A transport failure that does not mean "offline", such as a TLS |
| 29 | /// failure. Reporting a bad certificate as offline would hide a real fault |
| 30 | /// behind a banner that tells the person to wait for a connection. |
| 31 | case transport(URLError.Code) |
| 32 | |
| 33 | public enum RetryClass: Sendable, Equatable { |
| 34 | /// Wait for connectivity, then retry. |
| 35 | case offline |
| 36 | /// Retry with backoff. |
| 37 | case transient |
| 38 | /// Retrying cannot help until the person signs in again. |
| 39 | case auth |
| 40 | /// Retrying the same request cannot succeed. |
| 41 | case permanent |
| 42 | /// The app decides; the server kept its own version. |
| 43 | case conflict |
| 44 | } |
| 45 | |
| 46 | public var retryClass: RetryClass { |
| 47 | switch self { |
| 48 | case .offline: return .offline |
| 49 | case .unauthorized: return .auth |
| 50 | case .conflict: return .conflict |
| 51 | case .rateLimited, .server, .transport: return .transient |
| 52 | case .message, .forbidden, .notFound, .tooLarge, .rejected, .invalidResponse: return .permanent |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// Today's strings, so existing callers that show `localizedDescription` |
| 57 | /// read exactly as they did before the cases were typed. |
| 58 | public var errorDescription: String? { |
| 59 | switch self { |
| 60 | case .message(let value): return value |
| 61 | case .offline(let code), .transport(let code): return URLError(code).localizedDescription |
| 62 | case .unauthorized: return "Your session has ended. Sign out and sign in again." |
| 63 | case .notFound: return "This server does not support this native feature yet." |
| 64 | case .forbidden: return Self.unconfirmed(403) |
| 65 | case .conflict: return Self.unconfirmed(409) |
| 66 | case .tooLarge: return Self.unconfirmed(413) |
| 67 | case .rateLimited: return "The server is busy. Your changes have not been confirmed." |
| 68 | case .rejected(let status, _), .server(let status): return Self.unconfirmed(status) |
| 69 | case .invalidResponse: return "Invalid server response." |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | private static func unconfirmed(_ status: Int) -> String { |
| 74 | "The request could not be completed (\(status)). Your changes have not been confirmed." |
| 75 | } |
| 76 | |
| 77 | /// Transport failures that mean the network, not the server, is missing. |
| 78 | static let offlineCodes: Set<URLError.Code> = [ |
| 79 | .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, |
| 80 | .dnsLookupFailed, .timedOut, .dataNotAllowed, .internationalRoamingOff, |
| 81 | ] |
| 82 | |
| 83 | /// Maps a URLSession failure to a typed case. A cancelled task surfaces as |
| 84 | /// `CancellationError`, like every other cancelled Swift task: URLSession's |
| 85 | /// own -999 would otherwise reach callers that match cancellation by type |
| 86 | /// and read as a failure. Errors that are not URLErrors pass through untouched. |
| 87 | static func classify(_ error: Error) -> Error { |
| 88 | guard let failure = error as? URLError else { return error } |
| 89 | if failure.code == .cancelled { return CancellationError() } |
| 90 | return offlineCodes.contains(failure.code) ? NativeClientError.offline(failure.code) : NativeClientError.transport(failure.code) |
| 91 | } |
| 92 | |
| 93 | /// The typed failure for an HTTP status, or nil for 2xx. |
| 94 | static func forStatus(_ status: Int, body: Data, headers: [String: String], now: Date = Date()) -> NativeClientError? { |
| 95 | switch status { |
| 96 | case 200..<300: return nil |
| 97 | case 401, 303: return .unauthorized |
| 98 | case 403: return .forbidden |
| 99 | case 404: return .notFound |
| 100 | case 409: return .conflict(body) |
| 101 | case 413: return .tooLarge |
| 102 | case 429: return .rateLimited(retryAfter: retryAfter(headers["retry-after"], now: now)) |
| 103 | case 503: |
| 104 | if let header = headers["retry-after"] { return .rateLimited(retryAfter: retryAfter(header, now: now)) } |
| 105 | return .server(status: status) |
| 106 | case 500..<600: return .server(status: status) |
| 107 | default: return .rejected(status: status, body: body) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// Retry-After is either delay-seconds or an HTTP-date (RFC 9110 §10.2.3). |
| 112 | static func retryAfter(_ header: String?, now: Date) -> TimeInterval? { |
| 113 | guard let text = header?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil } |
| 114 | if let seconds = Int(text), seconds >= 0 { return TimeInterval(seconds) } |
| 115 | let formatter = DateFormatter() |
| 116 | formatter.locale = Locale(identifier: "en_US_POSIX") |
| 117 | formatter.timeZone = TimeZone(identifier: "GMT") |
| 118 | formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" |
| 119 | guard let date = formatter.date(from: text) else { return nil } |
| 120 | return max(0, date.timeIntervalSince(now)) |
| 121 | } |
| 122 | } |
| 123 | |