From ec2a6f11a75d75584a87bd566ab4237e2b67e2c8 Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Sat, 19 Sep 2026 18:44:39 +0530 Subject: [PATCH] feat: enhance API error handling and server configuration - Added new error case for unexpected server responses in APIError. - Improved error messages for server responses to provide clearer feedback. - Updated legacy hosts in ServerConfig to include additional URLs. - Added tests for JSON response validation and server message extraction. --- .../Services/ServerConfig.swift | 4 +- .../Services/SupercodeAPIClient.swift | 67 +++++++++++++------ .../SupercodeDesktopTests/ParityTests.swift | 30 +++++++++ 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/apps/supercode-desktop/SupercodeDesktop/Services/ServerConfig.swift b/apps/supercode-desktop/SupercodeDesktop/Services/ServerConfig.swift index a2608ec..7e1c379 100644 --- a/apps/supercode-desktop/SupercodeDesktop/Services/ServerConfig.swift +++ b/apps/supercode-desktop/SupercodeDesktop/Services/ServerConfig.swift @@ -29,9 +29,11 @@ enum ServerConfig { #endif } - /// Legacy hosts that should migrate to the current build default once. + /// Legacy or web-only hosts that should migrate to the current API host once. private static let legacyHosts: Set = [ "https://supercode-8w7e.onrender.com", + "https://supercli.com", + "https://www.supercli.com", "http://localhost:10000", "http://127.0.0.1:10000", ] diff --git a/apps/supercode-desktop/SupercodeDesktop/Services/SupercodeAPIClient.swift b/apps/supercode-desktop/SupercodeDesktop/Services/SupercodeAPIClient.swift index 5c477e3..c974e9e 100644 --- a/apps/supercode-desktop/SupercodeDesktop/Services/SupercodeAPIClient.swift +++ b/apps/supercode-desktop/SupercodeDesktop/Services/SupercodeAPIClient.swift @@ -4,6 +4,7 @@ enum APIError: LocalizedError { case invalidURL case unauthorized case server(String) + case unexpectedResponse(statusCode: Int, contentType: String?) case decoding case cancelled @@ -12,6 +13,9 @@ enum APIError: LocalizedError { case .invalidURL: return "Invalid server URL" case .unauthorized: return "Unauthorized — please sign in again" case .server(let message): return message + case .unexpectedResponse(let statusCode, let contentType): + let type = contentType ?? "unknown content type" + return "The Supercode server returned HTTP \(statusCode) as \(type), not JSON. Check the server URL and try again." case .decoding: return "Failed to decode server response" case .cancelled: return "Request cancelled" } @@ -158,29 +162,25 @@ actor SupercodeAPIClient { // MARK: - Device auth (Better Auth) func requestDeviceCode() async throws -> DeviceCodeResponse { - // better-auth device plugin endpoints under /api/auth let payload = try JSONSerialization.data(withJSONObject: [ "client_id": clientID, "scope": "openid profile email", ]) - let candidates = [ - "/api/auth/device/code", - "/api/auth/device/authorize", - ] - var lastError: Error = APIError.server("Device auth unavailable") - for path in candidates { - do { - let (data, http) = try await request("POST", path: path, body: payload, authorized: false) - if (200..<300).contains(http.statusCode) { - return try decoder.decode(DeviceCodeResponse.self, from: data) - } - let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" - lastError = APIError.server(message) - } catch { - lastError = error - } + let (data, http) = try await request( + "POST", + path: "/api/auth/device/code", + body: payload, + authorized: false + ) + try Self.requireJSON(http) + guard (200..<300).contains(http.statusCode) else { + throw APIError.server(Self.serverMessage(from: data, statusCode: http.statusCode)) + } + do { + return try decoder.decode(DeviceCodeResponse.self, from: data) + } catch { + throw APIError.decoding } - throw lastError } func pollDeviceToken(deviceCode: String) async throws -> TokenResponse? { @@ -190,6 +190,7 @@ actor SupercodeAPIClient { "client_id": clientID, ]) let (data, http) = try await request("POST", path: "/api/auth/device/token", body: payload, authorized: false) + try Self.requireJSON(http) if http.statusCode == 400 || http.statusCode == 403 { if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { let err = (obj["error"] as? String) ?? "" @@ -203,10 +204,34 @@ actor SupercodeAPIClient { return nil } guard (200..<300).contains(http.statusCode) else { - let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" - throw APIError.server(message) + throw APIError.server(Self.serverMessage(from: data, statusCode: http.statusCode)) + } + do { + return try decoder.decode(TokenResponse.self, from: data) + } catch { + throw APIError.decoding + } + } + + nonisolated static func requireJSON(_ response: HTTPURLResponse) throws { + let contentType = response.value(forHTTPHeaderField: "Content-Type") + guard contentType?.lowercased().contains("application/json") == true else { + throw APIError.unexpectedResponse( + statusCode: response.statusCode, + contentType: contentType + ) + } + } + + nonisolated static func serverMessage(from data: Data, statusCode: Int) -> String { + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + for key in ["error_description", "message", "error"] { + if let message = object[key] as? String, !message.isEmpty { + return message + } + } } - return try decoder.decode(TokenResponse.self, from: data) + return "Supercode authentication failed with HTTP \(statusCode)." } // MARK: - User / conversations diff --git a/apps/supercode-desktop/SupercodeDesktopTests/ParityTests.swift b/apps/supercode-desktop/SupercodeDesktopTests/ParityTests.swift index 916abad..f50c335 100644 --- a/apps/supercode-desktop/SupercodeDesktopTests/ParityTests.swift +++ b/apps/supercode-desktop/SupercodeDesktopTests/ParityTests.swift @@ -14,6 +14,9 @@ final class ParityTests: XCTestCase { XCTAssertEqual(ServerConfig.resolvedClientID(stored: " \n"), "ai.supercode.desktop") XCTAssertEqual(ServerConfig.resolvedClientID(stored: " custom "), "custom") XCTAssertEqual(ServerConfig.resolvedURL(stored: nil), ServerConfig.defaultURL) + XCTAssertEqual(ServerConfig.resolvedURL(stored: "https://supercli.com/"), ServerConfig.defaultURL) + XCTAssertEqual(ServerConfig.resolvedURL(stored: "https://www.supercli.com"), ServerConfig.defaultURL) + XCTAssertEqual(ServerConfig.resolvedURL(stored: "https://custom.example.com/"), "https://custom.example.com") XCTAssertEqual(URL(string: ServerConfig.productionURL)?.scheme, "https") #if DEBUG XCTAssertEqual(ServerConfig.defaultURL, ServerConfig.localURL) @@ -144,6 +147,33 @@ final class ParityTests: XCTestCase { XCTAssertEqual(object["literal"] as? Bool, true) } + func testAuthResponseValidation() throws { + let json = try XCTUnwrap(HTTPURLResponse( + url: URL(string: "https://supercode-terminal.vercel.app/api/auth/device/code")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json; charset=utf-8"] + )) + XCTAssertNoThrow(try SupercodeAPIClient.requireJSON(json)) + + let html = try XCTUnwrap(HTTPURLResponse( + url: URL(string: "https://supercli.com/login")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )) + XCTAssertThrowsError(try SupercodeAPIClient.requireJSON(html)) { error in + XCTAssertTrue(error.localizedDescription.contains("not JSON")) + XCTAssertFalse(error.localizedDescription.contains("DOCTYPE")) + } + + let payload = Data(#"{"error":"authorization_pending"}"#.utf8) + XCTAssertEqual( + SupercodeAPIClient.serverMessage(from: payload, statusCode: 400), + "authorization_pending" + ) + } + func testNDJSONDecoding() throws { XCTAssertEqual(try SupercodeAPIClient.decodeEvent(#"{"type":"status","message":"waiting"}"#), .status("waiting")) XCTAssertEqual(try SupercodeAPIClient.decodeEvent(#"{"type":"reasoning","content":"analysis"}"#), .reasoning("analysis"))