krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.7: Hutch/Networking/SRHTError.swift · raw
1import Foundation
2
3/// Errors produced by the SourceHut GraphQL client.
4enum SRHTError: LocalizedError, Sendable {
5 /// The server returned one or more GraphQL-level errors.
6 case graphQLErrors([GraphQLError])
7 /// The HTTP response had a non-2xx status code.
8 case httpError(Int)
9 /// The client refused to send credentials to an unexpected URL.
10 case invalidAuthenticatedURL(URL)
11 /// The response data could not be decoded.
12 case decodingError(any Error)
13 /// A networking error from URLSession (timeout, DNS, connectivity, etc.).
14 case networkError(any Error)
15 /// 401 or no authentication token configured.
16 case unauthorized
17
18 var errorDescription: String? {
19 switch self {
20 case .graphQLErrors(let errors):
21 let messages = errors.diagnosticSummary
22 return "GraphQL error: \(messages)"
23 case .httpError(let code):
24 return "Server returned HTTP \(code)."
25 case .invalidAuthenticatedURL(let url):
26 return "Refused to authenticate request to unexpected URL: \(url.absoluteString)"
27 case .decodingError(let error):
28 return "Failed to decode response: \(error.localizedDescription)"
29 case .networkError(let error):
30 return "Network error: \(error.localizedDescription)"
31 case .unauthorized:
32 return "Authentication required. Please sign in again."
33 }
34 }
35
36 nonisolated var userFacingMessage: String {
37 switch self {
38 case .graphQLErrors(let errors):
39 switch errors.classification {
40 case .unauthorized, .forbidden:
41 return "You do not have permission to do that."
42 case .notFound, .noRows, .missingReference, .unknownRevision:
43 return "That content is no longer available."
44 case .serviceNotProvisioned:
45 return "That account needs to activate this SourceHut service before this action can succeed."
46 case .validation:
47 return errors.primaryMessage ?? "Please review your changes and try again."
48 case .other:
49 return "Something went wrong. Please try again."
50 }
51 case .httpError(let code):
52 if code == 401 {
53 return "Please sign in again."
54 }
55 if code == 403 {
56 return "You do not have permission to do that."
57 }
58 if code == 404 {
59 return "That content is no longer available."
60 }
61 if (500...599).contains(code) {
62 return "The server is unavailable right now. Please try again."
63 }
64 return "Something went wrong. Please try again."
65 case .invalidAuthenticatedURL:
66 return "That request could not be completed."
67 case .decodingError:
68 return "The response could not be loaded right now."
69 case .networkError(let error):
70 let nsError = error as NSError
71 switch nsError.code {
72 case NSURLErrorNotConnectedToInternet,
73 NSURLErrorNetworkConnectionLost,
74 NSURLErrorTimedOut,
75 NSURLErrorCannotFindHost,
76 NSURLErrorCannotConnectToHost,
77 NSURLErrorDNSLookupFailed,
78 NSURLErrorInternationalRoamingOff,
79 NSURLErrorDataNotAllowed:
80 return "Check your connection and try again."
81 default:
82 return "The network request failed. Please try again."
83 }
84 case .unauthorized:
85 return "Please sign in again."
86 }
87 }
88
89 /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
90 var isConnectivityError: Bool {
91 switch self {
92 case .networkError(let error):
93 let nsError = error as NSError
94 let connectivityCodes: Set<Int> = [
95 NSURLErrorNotConnectedToInternet,
96 NSURLErrorNetworkConnectionLost,
97 NSURLErrorTimedOut,
98 NSURLErrorCannotFindHost,
99 NSURLErrorCannotConnectToHost,
100 NSURLErrorDNSLookupFailed,
101 NSURLErrorInternationalRoamingOff,
102 NSURLErrorDataNotAllowed
103 ]
104 return connectivityCodes.contains(nsError.code)
105 default:
106 return false
107 }
108 }
109}
110
111extension Error {
112 nonisolated var userFacingMessage: String {
113 if let error = self as? SRHTError {
114 return error.userFacingMessage
115 }
116
117 let nsError = self as NSError
118 switch nsError.code {
119 case NSURLErrorNotConnectedToInternet,
120 NSURLErrorNetworkConnectionLost,
121 NSURLErrorTimedOut,
122 NSURLErrorCannotFindHost,
123 NSURLErrorCannotConnectToHost,
124 NSURLErrorDNSLookupFailed,
125 NSURLErrorInternationalRoamingOff,
126 NSURLErrorDataNotAllowed:
127 return "Check your connection and try again."
128 default:
129 return "Something went wrong. Please try again."
130 }
131 }
132
133 nonisolated var graphQLErrors: [GraphQLError]? {
134 guard let srhtError = self as? SRHTError,
135 case let SRHTError.graphQLErrors(errors) = srhtError else {
136 return nil
137 }
138 return errors
139 }
140
141 nonisolated func matchesGraphQLErrorClassification(_ classification: GraphQLErrorClassification) -> Bool {
142 graphQLErrors?.classification == classification
143 }
144
145 nonisolated func containsGraphQLErrorMessage(_ fragment: String) -> Bool {
146 graphQLErrors?.containsMessage(fragment) == true
147 }
148}
149
150/// A single error entry from the GraphQL `errors` array.
151struct GraphQLError: Decodable, Sendable {
152 let message: String
153 let locations: [GraphQLErrorLocation]?
154}
155
156enum GraphQLErrorClassification: Sendable {
157 case unauthorized
158 case forbidden
159 case notFound
160 case noRows
161 case missingReference
162 case unknownRevision
163 case serviceNotProvisioned
164 case validation
165 case other
166}
167
168extension Array where Element == GraphQLError {
169 nonisolated var classification: GraphQLErrorClassification {
170 if containsMessage("unauthorized") { return .unauthorized }
171 if containsMessage("forbidden") { return .forbidden }
172 if containsMessage("reference not found") { return .missingReference }
173 if containsMessage("no rows in result set") { return .noRows }
174 if containsMessage("unknown revision") || containsMessage("path not in the working tree") {
175 return .unknownRevision
176 }
177 if containsMessage("not found") || containsMessage("no such") || containsMessage("missing revision") {
178 return .notFound
179 }
180 if containsMessage("no such repository or user found") {
181 return .serviceNotProvisioned
182 }
183 if let primaryMessage, !primaryMessage.isEmpty {
184 return .validation
185 }
186 return .other
187 }
188
189 nonisolated var primaryMessage: String? {
190 let candidates = map(\.message)
191 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
192 .filter { !$0.isEmpty }
193 return candidates.first
194 }
195
196 nonisolated var diagnosticSummary: String {
197 map(\.message)
198 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
199 .filter { !$0.isEmpty }
200 .joined(separator: "\n")
201 }
202
203 nonisolated func containsMessage(_ fragment: String) -> Bool {
204 contains { $0.message.localizedCaseInsensitiveContains(fragment) }
205 }
206}
207
208struct GraphQLErrorLocation: Decodable, Sendable {
209 let line: Int
210 let column: Int
211}