Explorar el Código

Sanitize user-facing errors so API and internal failures never reach the UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hace 1 mes
padre
commit
57c08c9d18

+ 3 - 3
gramora/Models/DictionaryModels.swift

@@ -32,15 +32,15 @@ enum DictionaryServiceError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingAPIKey:
-            "OpenAI API key is missing."
+            UserFacingError.generic
         case .emptyInput:
             "Enter a word to look up."
         case .wordNotFound:
             "No definition found for that word."
         case .invalidResponse:
-            "Could not read the dictionary response."
+            UserFacingError.generic
         case .networkError(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         }
     }
 }

+ 3 - 3
gramora/Models/GrammarCheckModels.swift

@@ -21,13 +21,13 @@ enum GrammarCheckServiceError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingAPIKey:
-            "OpenAI API key is missing."
+            UserFacingError.generic
         case .emptyInput:
             "Enter some text to check."
         case .invalidResponse:
-            "Could not read the grammar check response."
+            UserFacingError.generic
         case .apiError(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         }
     }
 }

+ 3 - 3
gramora/Models/JournalFinderModels.swift

@@ -69,11 +69,11 @@ enum JournalFinderServiceError: LocalizedError {
         case .emptyInput:
             "Enter an abstract or manuscript summary to find matching journals."
         case .missingAPIKey:
-            "OpenAI API key is not configured."
+            UserFacingError.generic
         case .invalidResponse:
-            "Could not read the journal finder response."
+            UserFacingError.generic
         case .apiError(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         }
     }
 }

+ 1 - 1
gramora/Services/DictionaryService.swift

@@ -68,7 +68,7 @@ struct DictionaryService: DictionaryServicing {
         } catch let error as DictionaryServiceError {
             throw error
         } catch let error as URLError {
-            throw DictionaryServiceError.networkError(error.localizedDescription)
+            throw DictionaryServiceError.networkError(UserFacingError.networkMessage(for: error))
         } catch {
             throw DictionaryServiceError.invalidResponse
         }

+ 21 - 14
gramora/Services/OpenAIService.swift

@@ -9,13 +9,13 @@ enum OpenAIServiceError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingAPIKey:
-            "OpenAI API key is missing."
+            UserFacingError.generic
         case .invalidResponse:
-            "Could not read the response from OpenAI."
+            UserFacingError.generic
         case .networkError(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         case .apiError(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         }
     }
 }
@@ -113,8 +113,7 @@ struct OpenAIChatService: OpenAIChatServicing {
         }
 
         if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
-            let message = Self.errorMessage(from: data) ?? "Request failed with status \(httpResponse.statusCode)."
-            throw OpenAIServiceError.apiError(message)
+            throw OpenAIServiceError.apiError(Self.userFacingHTTPError(statusCode: httpResponse.statusCode, data: data))
         }
 
         let completion = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
@@ -126,15 +125,23 @@ struct OpenAIChatService: OpenAIChatServicing {
     }
 
     private static func userFacingMessage(for error: URLError) -> String {
-        switch error.code {
-        case .notConnectedToInternet:
-            "You appear to be offline. Check your internet connection and try again."
-        case .timedOut:
-            "The request timed out. Please try again."
-        case .networkConnectionLost:
-            "The connection was interrupted. Please try again."
+        UserFacingError.networkMessage(for: error)
+    }
+
+    private static func userFacingHTTPError(statusCode: Int, data: Data) -> String {
+        switch statusCode {
+        case 401, 403:
+            return UserFacingError.generic
+        case 429:
+            return "Too many requests. Please wait a moment and try again."
+        case 500...599:
+            return "The service is temporarily unavailable. Please try again."
         default:
-            "A network error occurred. Please try again."
+            if let message = errorMessage(from: data),
+               let sanitized = UserFacingError.sanitized(message) {
+                return sanitized
+            }
+            return UserFacingError.generic
         }
     }
 

+ 1 - 1
gramora/Services/SpellCheckService.swift

@@ -114,7 +114,7 @@ struct SpellCheckService: SpellCheckServicing {
                 return firstCompleted
             }
         } catch let error as DecodingError {
-            throw OpenAIServiceError.apiError("Spelling response format was invalid. Please try again.")
+            throw OpenAIServiceError.invalidResponse
         } catch {
             throw error
         }

+ 3 - 3
gramora/Services/TextExtractionService.swift

@@ -104,8 +104,8 @@ struct TextExtractionService: TextExtracting {
 
         return try await withCheckedThrowingContinuation { continuation in
             let request = VNRecognizeTextRequest { request, error in
-                if let error {
-                    continuation.resume(throwing: error)
+                if error != nil {
+                    continuation.resume(throwing: TextExtractionServiceError.readFailed)
                     return
                 }
 
@@ -129,7 +129,7 @@ struct TextExtractionService: TextExtracting {
             do {
                 try handler.perform([request])
             } catch {
-                continuation.resume(throwing: error)
+                continuation.resume(throwing: TextExtractionServiceError.readFailed)
             }
         }
     }

+ 73 - 0
gramora/Utilities/UserFacingError.swift

@@ -0,0 +1,73 @@
+import Foundation
+
+enum UserFacingError {
+    static let generic = "Something went wrong. Please try again."
+
+    static func message(for error: Error) -> String {
+        if shouldUseGenericMessage(for: error) {
+            return generic
+        }
+
+        let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+        return sanitized(raw) ?? generic
+    }
+
+    static func networkMessage(for error: URLError) -> String {
+        switch error.code {
+        case .notConnectedToInternet:
+            "You appear to be offline. Check your internet connection and try again."
+        case .timedOut:
+            "The request timed out. Please try again."
+        case .networkConnectionLost:
+            "The connection was interrupted. Please try again."
+        default:
+            generic
+        }
+    }
+
+    static func sanitized(_ message: String) -> String? {
+        let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return nil }
+        guard !containsSensitiveContent(trimmed.lowercased()) else { return nil }
+        return trimmed
+    }
+
+    private static func shouldUseGenericMessage(for error: Error) -> Bool {
+        if error is DecodingError || error is EncodingError {
+            return true
+        }
+
+        switch error {
+        case OpenAIServiceError.missingAPIKey,
+             OpenAIServiceError.invalidResponse,
+             DictionaryServiceError.missingAPIKey,
+             DictionaryServiceError.invalidResponse,
+             GrammarCheckServiceError.missingAPIKey,
+             GrammarCheckServiceError.invalidResponse,
+             JournalFinderServiceError.missingAPIKey,
+             JournalFinderServiceError.invalidResponse:
+            return true
+        default:
+            return false
+        }
+    }
+
+    private static func containsSensitiveContent(_ lower: String) -> Bool {
+        lower.contains("api key")
+            || lower.contains("apikey")
+            || lower.contains("openai")
+            || lower.contains("invalid_api_key")
+            || lower.contains("incorrect api key")
+            || lower.contains("bearer ")
+            || lower.contains("sk-")
+            || lower.contains("authentication")
+            || lower.contains("not configured")
+            || lower.contains("response format")
+            || lower.contains("request failed")
+            || lower.contains("nsurlerrordomain")
+            || lower.contains("cocoaerrordomain")
+            || lower.contains("error domain")
+            || (lower.contains("could not read") && lower.contains("response"))
+            || (lower.contains("status") && lower.range(of: #"\b[1-5]\d{2}\b"#, options: .regularExpression) != nil)
+    }
+}

+ 1 - 1
gramora/ViewModels/DictionaryViewModel.swift

@@ -134,7 +134,7 @@ final class DictionaryViewModel: ObservableObject {
             addRecentSearch(result.word)
         } catch {
             guard !Task.isCancelled, activeLookupID == lookupID else { return }
-            errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+            errorMessage = UserFacingError.message(for: error)
         }
 
         guard activeLookupID == lookupID else { return }

+ 1 - 1
gramora/ViewModels/EmailWriterViewModel.swift

@@ -102,7 +102,7 @@ final class EmailWriterViewModel: ObservableObject {
                     tone: requestTone
                 )
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isGenerating = false

+ 2 - 2
gramora/ViewModels/GrammarCheckerViewModel.swift

@@ -53,7 +53,7 @@ final class GrammarCheckerViewModel: ObservableObject {
                 text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 resetResults()
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
         }
     }
@@ -105,7 +105,7 @@ final class GrammarCheckerViewModel: ObservableObject {
                 hasResults = true
                 saveToHistory(text: content, issues: result.issues, correctedText: result.correctedText)
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isChecking = false

+ 1 - 1
gramora/ViewModels/JournalFinderViewModel.swift

@@ -115,7 +115,7 @@ final class JournalFinderViewModel: ObservableObject {
                 hasResults = true
                 saveToHistory(criteria: searchCriteria, result: searchResult)
             } catch {
-                errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isSearching = false

+ 2 - 2
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -56,7 +56,7 @@ final class LanguageTranslatorViewModel: ObservableObject {
                 sourceText = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 translatedText = ""
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
         }
     }
@@ -119,7 +119,7 @@ final class LanguageTranslatorViewModel: ObservableObject {
                     targetLanguage: requestTargetLanguage
                 )
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isTranslating = false

+ 2 - 2
gramora/ViewModels/ParaphrasingViewModel.swift

@@ -50,7 +50,7 @@ final class ParaphrasingViewModel: ObservableObject {
                 text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 paraphrasedText = ""
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
         }
     }
@@ -96,7 +96,7 @@ final class ParaphrasingViewModel: ObservableObject {
                 paraphrasedText = result
                 saveToHistory(text: content, paraphrasedText: result)
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isParaphrasing = false

+ 2 - 2
gramora/ViewModels/PunctuationCheckerViewModel.swift

@@ -48,7 +48,7 @@ final class PunctuationCheckerViewModel: ObservableObject {
                 text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 resetResults()
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
         }
     }
@@ -100,7 +100,7 @@ final class PunctuationCheckerViewModel: ObservableObject {
                 hasResults = true
                 saveToHistory(text: content, issues: result.issues, correctedText: result.correctedText)
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isChecking = false

+ 2 - 2
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -53,7 +53,7 @@ final class SpellCheckerViewModel: ObservableObject {
                 text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 resetResults()
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
         }
     }
@@ -105,7 +105,7 @@ final class SpellCheckerViewModel: ObservableObject {
                 hasResults = true
                 saveToHistory(text: content, mistakes: result.mistakes, correctedText: result.correctedText)
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isChecking = false

+ 2 - 2
gramora/ViewModels/SummarizerViewModel.swift

@@ -50,7 +50,7 @@ final class SummarizerViewModel: ObservableObject {
                 text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
                 summary = ""
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
         }
     }
@@ -96,7 +96,7 @@ final class SummarizerViewModel: ObservableObject {
                 summary = result
                 saveToHistory(text: content, summary: result)
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
 
             isSummarizing = false

+ 1 - 1
gramora/ViewModels/WordsCountViewModel.swift

@@ -57,7 +57,7 @@ final class WordsCountViewModel: ObservableObject {
                 guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
                 text = WordLimit.applyImported(extracted, maxWords: Self.maxWords, setError: { errorMessage = $0 })
             } catch {
-                errorMessage = error.localizedDescription
+                errorMessage = UserFacingError.message(for: error)
             }
         }
     }