rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import 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.
7public 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