summaryrefslogtreecommitdiff
path: root/Hutch/Networking/SRHTError.swift
blob: e94677490295470e8eeb587c217154d0aba95e90 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import Foundation

/// Errors produced by the SourceHut GraphQL client.
enum SRHTError: LocalizedError, Sendable {
    /// The server returned one or more GraphQL-level errors.
    case graphQLErrors([GraphQLError])
    /// The HTTP response had a non-2xx status code.
    case httpError(Int)
    /// The client refused to send credentials to an unexpected URL.
    case invalidAuthenticatedURL(URL)
    /// The response data could not be decoded.
    case decodingError(any Error)
    /// A networking error from URLSession (timeout, DNS, connectivity, etc.).
    case networkError(any Error)
    /// 401 or no authentication token configured.
    case unauthorized

    var errorDescription: String? {
        switch self {
        case .graphQLErrors(let errors):
            let messages = errors.diagnosticSummary
            return "GraphQL error: \(messages)"
        case .httpError(let code):
            return "Server returned HTTP \(code)."
        case .invalidAuthenticatedURL(let url):
            return "Refused to authenticate request to unexpected URL: \(url.absoluteString)"
        case .decodingError(let error):
            return "Failed to decode response: \(error.localizedDescription)"
        case .networkError(let error):
            return "Network error: \(error.localizedDescription)"
        case .unauthorized:
            return "Authentication required. Please sign in again."
        }
    }

    nonisolated var userFacingMessage: String {
        switch self {
        case .graphQLErrors(let errors):
            switch errors.classification {
            case .unauthorized, .forbidden:
                return "You do not have permission to do that."
            case .notFound, .noRows, .missingReference, .unknownRevision:
                return "That content is no longer available."
            case .serviceNotProvisioned:
                return "That account needs to activate this SourceHut service before this action can succeed."
            case .validation:
                return errors.primaryMessage ?? "Please review your changes and try again."
            case .other:
                return "Something went wrong. Please try again."
            }
        case .httpError(let code):
            if code == 401 {
                return "Please sign in again."
            }
            if code == 403 {
                return "You do not have permission to do that."
            }
            if code == 404 {
                return "That content is no longer available."
            }
            if (500...599).contains(code) {
                return "The server is unavailable right now. Please try again."
            }
            return "Something went wrong. Please try again."
        case .invalidAuthenticatedURL:
            return "That request could not be completed."
        case .decodingError:
            return "The response could not be loaded right now."
        case .networkError(let error):
            let nsError = error as NSError
            switch nsError.code {
            case NSURLErrorNotConnectedToInternet,
                 NSURLErrorNetworkConnectionLost,
                 NSURLErrorTimedOut,
                 NSURLErrorCannotFindHost,
                 NSURLErrorCannotConnectToHost,
                 NSURLErrorDNSLookupFailed,
                 NSURLErrorInternationalRoamingOff,
                 NSURLErrorDataNotAllowed:
                return "Check your connection and try again."
            default:
                return "The network request failed. Please try again."
            }
        case .unauthorized:
            return "Please sign in again."
        }
    }

    /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
    var isConnectivityError: Bool {
        switch self {
        case .networkError(let error):
            let nsError = error as NSError
            let connectivityCodes: Set<Int> = [
                NSURLErrorNotConnectedToInternet,
                NSURLErrorNetworkConnectionLost,
                NSURLErrorTimedOut,
                NSURLErrorCannotFindHost,
                NSURLErrorCannotConnectToHost,
                NSURLErrorDNSLookupFailed,
                NSURLErrorInternationalRoamingOff,
                NSURLErrorDataNotAllowed
            ]
            return connectivityCodes.contains(nsError.code)
        default:
            return false
        }
    }
}

extension Error {
    nonisolated var userFacingMessage: String {
        if let error = self as? SRHTError {
            return error.userFacingMessage
        }

        let nsError = self as NSError
        switch nsError.code {
        case NSURLErrorNotConnectedToInternet,
             NSURLErrorNetworkConnectionLost,
             NSURLErrorTimedOut,
             NSURLErrorCannotFindHost,
             NSURLErrorCannotConnectToHost,
             NSURLErrorDNSLookupFailed,
             NSURLErrorInternationalRoamingOff,
             NSURLErrorDataNotAllowed:
            return "Check your connection and try again."
        default:
            return "Something went wrong. Please try again."
        }
    }

    nonisolated var graphQLErrors: [GraphQLError]? {
        guard let srhtError = self as? SRHTError,
              case let SRHTError.graphQLErrors(errors) = srhtError else {
            return nil
        }
        return errors
    }

    nonisolated func matchesGraphQLErrorClassification(_ classification: GraphQLErrorClassification) -> Bool {
        graphQLErrors?.classification == classification
    }

    nonisolated func containsGraphQLErrorMessage(_ fragment: String) -> Bool {
        graphQLErrors?.containsMessage(fragment) == true
    }
}

/// A single error entry from the GraphQL `errors` array.
struct GraphQLError: Decodable, Sendable {
    let message: String
    let locations: [GraphQLErrorLocation]?
}

enum GraphQLErrorClassification: Sendable {
    case unauthorized
    case forbidden
    case notFound
    case noRows
    case missingReference
    case unknownRevision
    case serviceNotProvisioned
    case validation
    case other
}

extension Array where Element == GraphQLError {
    nonisolated var classification: GraphQLErrorClassification {
        if containsMessage("unauthorized") { return .unauthorized }
        if containsMessage("forbidden") { return .forbidden }
        if containsMessage("reference not found") { return .missingReference }
        if containsMessage("no rows in result set") { return .noRows }
        if containsMessage("unknown revision") || containsMessage("path not in the working tree") {
            return .unknownRevision
        }
        if containsMessage("not found") || containsMessage("no such") || containsMessage("missing revision") {
            return .notFound
        }
        if containsMessage("no such repository or user found") {
            return .serviceNotProvisioned
        }
        if let primaryMessage, !primaryMessage.isEmpty {
            return .validation
        }
        return .other
    }

    nonisolated var primaryMessage: String? {
        let candidates = map(\.message)
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
        return candidates.first
    }

    nonisolated var diagnosticSummary: String {
        map(\.message)
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .joined(separator: "\n")
    }

    nonisolated func containsMessage(_ fragment: String) -> Bool {
        contains { $0.message.localizedCaseInsensitiveContains(fragment) }
    }
}

struct GraphQLErrorLocation: Decodable, Sendable {
    let line: Int
    let column: Int
}