From c9fbb1bf6e29be20f63a3ac99f5b14c33171c36e Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Fri, 13 Dec 2024 13:34:01 +1100
Subject: [PATCH 1/2] chore: add API errors to SDK

---
 .../xcshareddata/swiftpm/Package.resolved     |   2 +-
 .../Preview Content/PreviewClient.swift       |  38 +++---
 Coder Desktop/Coder Desktop/SDK/Client.swift  | 110 +++++++++++++++---
 Coder Desktop/Coder Desktop/SDK/User.swift    |  14 ++-
 .../Coder Desktop/Views/LoginForm.swift       |  43 ++++---
 .../Coder DesktopTests/AgentsTests.swift      |   2 +-
 .../Coder DesktopTests/LoginFormTests.swift   |  34 ++----
 Coder Desktop/Coder DesktopTests/Util.swift   |   6 +-
 .../Coder DesktopTests/VPNMenuTests.swift     |  10 +-
 .../Coder DesktopTests/VPNStateTests.swift    |  10 +-
 10 files changed, 171 insertions(+), 98 deletions(-)

diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index cb887ce..065b859 100644
--- a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved	
+++ b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved	
@@ -1,5 +1,5 @@
 {
-  "originHash" : "42dc2e0a0e0417a7f4f62b3e875c9559038beef7d2265073dd4fc81f2e11ee13",
+  "originHash" : "aa8dd97dc6e28dedc4a5c45c435467a247486474bf3c1caf5e67085d52325132",
   "pins" : [
     {
       "identity" : "alamofire",
diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift
index e21e8c6..592fa83 100644
--- a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift	
+++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift	
@@ -3,22 +3,26 @@ import SwiftUI
 struct PreviewClient: Client {
     init(url _: URL, token _: String? = nil) {}
 
-    func user(_: String) async throws -> User {
-        try await Task.sleep(for: .seconds(1))
-        return User(
-            id: UUID(),
-            username: "admin",
-            avatar_url: "",
-            name: "admin",
-            email: "admin@coder.com",
-            created_at: Date.now,
-            updated_at: Date.now,
-            last_seen_at: Date.now,
-            status: "active",
-            login_type: "none",
-            theme_preference: "dark",
-            organization_ids: [],
-            roles: []
-        )
+    func user(_: String) async throws(ClientError) -> User {
+        do {
+            try await Task.sleep(for: .seconds(1))
+            return User(
+                id: UUID(),
+                username: "admin",
+                avatar_url: "",
+                name: "admin",
+                email: "admin@coder.com",
+                created_at: Date.now,
+                updated_at: Date.now,
+                last_seen_at: Date.now,
+                status: "active",
+                login_type: "none",
+                theme_preference: "dark",
+                organization_ids: [],
+                roles: []
+            )
+        } catch {
+            throw ClientError.badResponse
+        }
     }
 }
diff --git a/Coder Desktop/Coder Desktop/SDK/Client.swift b/Coder Desktop/Coder Desktop/SDK/Client.swift
index 0e32e91..6058351 100644
--- a/Coder Desktop/Coder Desktop/SDK/Client.swift	
+++ b/Coder Desktop/Coder Desktop/SDK/Client.swift	
@@ -3,7 +3,7 @@ import Foundation
 
 protocol Client {
     init(url: URL, token: String?)
-    func user(_ ident: String) async throws -> User
+    func user(_ ident: String) async throws(ClientError) -> User
 }
 
 struct CoderClient: Client {
@@ -25,38 +25,122 @@ struct CoderClient: Client {
     func request<T: Encodable>(
         _ path: String,
         method: HTTPMethod,
-        body: T
-    ) async -> DataResponse<Data, AFError> {
+        body: T? = nil
+    ) async throws(ClientError) -> HTTPResponse {
         let url = self.url.appendingPathComponent(path)
-        let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
-        return await AF.request(
+        let headers: HTTPHeaders? = token.map { [Headers.sessionToken: $0] }
+        let out = await AF.request(
             url,
             method: method,
             parameters: body,
-            encoder: JSONParameterEncoder.default,
             headers: headers
         ).serializingData().response
+        guard let response = out.response else {
+            throw ClientError.noResponse
+        }
+        switch out.result {
+        case .success(let data):
+            return HTTPResponse(resp: response, data: data, req: out.request)
+        case .failure:
+            throw ClientError.badResponse
+        }
     }
 
     func request(
         _ path: String,
         method: HTTPMethod
-    ) async -> DataResponse<Data, AFError> {
+    ) async throws(ClientError) -> HTTPResponse {
         let url = self.url.appendingPathComponent(path)
-        let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
-        return await AF.request(
+        let headers: HTTPHeaders? = token.map { [Headers.sessionToken: $0] }
+        let out = await AF.request(
             url,
             method: method,
             headers: headers
         ).serializingData().response
+        guard let response = out.response else {
+            throw ClientError.noResponse
+        }
+        switch out.result {
+        case .success(let data):
+            return HTTPResponse(resp: response, data: data, req: out.request)
+        case .failure:
+            throw ClientError.badResponse
+        }
     }
+
+    func responseAsError(_ resp: HTTPResponse) throws(ClientError) -> APIError {
+        do {
+            let body = try CoderClient.decoder.decode(Response.self, from: resp.data)
+            return APIError(
+                response: body,
+                statusCode: resp.resp.statusCode,
+                method: resp.req?.httpMethod,
+                url: resp.req?.url
+            )
+        } catch {
+            throw ClientError.badResponse
+        }
+    }
+
+    enum Headers {
+        static let sessionToken = "Coder-Session-Token"
+    }
+
+}
+
+struct HTTPResponse {
+    let resp: HTTPURLResponse
+    let data: Data
+    let req: URLRequest?
+}
+
+struct APIError: Decodable {
+    let response: Response
+    let statusCode: Int
+    let method: String?
+    let url: URL?
+
+    var description: String {
+        var components: [String] = []
+        if let method = method, let url = url {
+            components.append("\(method) \(url.absoluteString)")
+        }
+        components.append("Unexpected status code \(statusCode):\n\(response.message)")
+        if let detail = response.detail {
+            components.append("\tError: \(detail)")
+        }
+        if let validations = response.validations, !validations.isEmpty {
+            let validationMessages = validations.map { "\t\($0.field): \($0.detail)" }
+            components.append(contentsOf: validationMessages)
+        }
+        return components.joined(separator: "\n")
+    }
+}
+
+struct Response: Decodable {
+    let message: String
+    let detail: String?
+    let validations: [ValidationError]?
+}
+
+struct ValidationError: Decodable {
+    let field: String
+    let detail: String
 }
 
 enum ClientError: Error {
-    case unexpectedStatusCode
+    case apiError(APIError)
     case badResponse
-}
+    case noResponse
 
-enum Headers {
-    static let sessionToken = "Coder-Session-Token"
+    var description: String {
+        switch self {
+        case .apiError(let error):
+            return error.description
+        case .badResponse:
+            return "Bad response"
+        case .noResponse:
+            return "No response"
+        }
+    }
 }
diff --git a/Coder Desktop/Coder Desktop/SDK/User.swift b/Coder Desktop/Coder Desktop/SDK/User.swift
index 4ca26eb..789c34f 100644
--- a/Coder Desktop/Coder Desktop/SDK/User.swift	
+++ b/Coder Desktop/Coder Desktop/SDK/User.swift	
@@ -1,15 +1,17 @@
 import Foundation
 
 extension CoderClient {
-    func user(_ ident: String) async throws -> User {
-        let resp = await request("/api/v2/users/\(ident)", method: .get)
-        guard let response = resp.response, response.statusCode == 200 else {
-            throw ClientError.unexpectedStatusCode
+    func user(_ ident: String) async throws(ClientError) -> User {
+        let res = try await request("/api/v2/users/\(ident)", method: .get)
+        guard res.resp.statusCode == 200 else {
+            let error = try responseAsError(res)
+            throw ClientError.apiError(error)
         }
-        guard let data = resp.data else {
+        do {
+            return try CoderClient.decoder.decode(User.self, from: res.data)
+        } catch {
             throw ClientError.badResponse
         }
-        return try CoderClient.decoder.decode(User.self, from: data)
     }
 }
 
diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift
index 2888d0b..c4fe153 100644
--- a/Coder Desktop/Coder Desktop/Views/LoginForm.swift	
+++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift	
@@ -37,19 +37,21 @@ struct LoginForm<C: Client, S: Session>: View {
             }
             .animation(.easeInOut, value: currentPage)
             .onAppear {
-                loginError = nil
                 baseAccessURL = session.baseAccessURL?.absoluteString ?? baseAccessURL
                 sessionToken = ""
-            }.padding(.top, 35)
-            VStack(alignment: .center) {
-                if let loginError {
-                    Text("\(loginError.description)")
-                        .font(.headline)
-                        .foregroundColor(.red)
-                        .multilineTextAlignment(.center)
+            }.padding(.vertical, 35)
+                .alert("Error", isPresented: Binding(
+                    get: { loginError != nil },
+                    set: { isPresented in
+                        if !isPresented {
+                            loginError = nil
+                        }
+                    }
+                )) {
+                    Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
+                } message: {
+                    Text(loginError?.description ?? "")
                 }
-            }
-            .frame(height: 35)
         }.padding()
             .frame(width: 450, height: 220)
             .disabled(loading)
@@ -57,9 +59,7 @@ struct LoginForm<C: Client, S: Session>: View {
     }
 
     internal func submit() async {
-        loginError = nil
         guard sessionToken != "" else {
-            loginError = .invalidToken
             return
         }
         guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
@@ -69,11 +69,10 @@ struct LoginForm<C: Client, S: Session>: View {
         loading = true
         defer { loading = false}
         let client = C(url: url, token: sessionToken)
-        do {
+        do throws(ClientError) {
             _ = try await client.user("me")
         } catch {
-            loginError = .failedAuth
-            print("Set error")
+            loginError = .failedAuth(error)
             return
         }
         session.store(baseAccessURL: url, sessionToken: sessionToken)
@@ -142,7 +141,9 @@ struct LoginForm<C: Client, S: Session>: View {
     }
 
     private func next() {
-        loginError = nil
+        guard baseAccessURL != "" else {
+            return
+        }
         guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
             loginError = .invalidURL
             return
@@ -155,7 +156,6 @@ struct LoginForm<C: Client, S: Session>: View {
 
     private func back() {
         withAnimation {
-            loginError = nil
             currentPage = .serverURL
             focusedField = .baseAccessURL
         }
@@ -164,17 +164,14 @@ struct LoginForm<C: Client, S: Session>: View {
 
 enum LoginError {
     case invalidURL
-    case invalidToken
-    case failedAuth
+    case failedAuth(ClientError)
 
     var description: String {
         switch self {
         case .invalidURL:
             return "Invalid URL"
-        case .invalidToken:
-            return "Invalid Session Token"
-        case .failedAuth:
-            return "Could not authenticate with Coder deployment"
+        case .failedAuth(let err):
+            return "Could not authenticate with Coder deployment:\n\(err.description)"
         }
     }
 }
diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift
index e6c679a..2563362 100644
--- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift	
+++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift	
@@ -56,7 +56,7 @@ struct AgentsTests {
         vpn.state = .connected
         vpn.agents = createMockAgents(count: 7)
 
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 var toggle = try view.find(ViewType.Toggle.self)
                 #expect(try toggle.labelView().text().string() == "Show All")
diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift
index 3f3b547..3bdbf6a 100644
--- a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift	
+++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift	
@@ -18,7 +18,7 @@ struct LoginTests {
     @Test
     @MainActor
     func testInitialView() async throws {
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 #expect(throws: Never.self) { try view.find(text: "Coder Desktop") }
                 #expect(throws: Never.self) { try view.find(text: "Server URL") }
@@ -30,11 +30,11 @@ struct LoginTests {
     @Test
     @MainActor
     func testInvalidServerURL() async throws {
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
-                try view.find(ViewType.TextField.self).setInput("")
+                try view.find(ViewType.TextField.self).setInput("http://")
                 try view.find(button: "Next").tap()
-                #expect(throws: Never.self) { try view.find(text: "Invalid URL") }
+                #expect(throws: Never.self) { try view.find(ViewType.Alert.self) }
             }
         }
     }
@@ -42,7 +42,7 @@ struct LoginTests {
     @Test
     @MainActor
     func testValidServerURL() async throws {
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
                 try view.find(button: "Next").tap()
@@ -57,7 +57,7 @@ struct LoginTests {
     @Test
     @MainActor
     func testBackButton() async throws {
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
                 try view.find(button: "Next").tap()
@@ -69,33 +69,19 @@ struct LoginTests {
         }
     }
 
-    @Test
-    @MainActor
-    func testInvalidSessionToken() async throws {
-        try await ViewHosting.host(view) { _ in
-            try await sut.inspection.inspect { view in
-                try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
-                try view.find(button: "Next").tap()
-                try view.find(ViewType.SecureField.self).setInput("")
-                try await view.actualView().submit()
-                #expect(throws: Never.self) { try view.find(text: "Invalid Session Token") }
-            }
-        }
-    }
-
     @Test
     @MainActor
     func testFailedAuthentication() async throws {
         let login = LoginForm<MockErrorClient, MockSession>()
 
-        try await ViewHosting.host(login.environmentObject(session)) { _ in
+        try await ViewHosting.host(login.environmentObject(session)) {
             try await login.inspection.inspect { view in
                 try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
                 try view.find(button: "Next").tap()
                 #expect(throws: Never.self) { try view.find(text: "Session Token") }
                 try view.find(ViewType.SecureField.self).setInput("valid-token")
                 try await view.actualView().submit()
-                #expect(throws: Never.self) { try view.find(text: "Could not authenticate with Coder deployment") }
+                #expect(throws: Never.self) { try view.find(ViewType.Alert.self) }
             }
         }
     }
@@ -103,12 +89,12 @@ struct LoginTests {
     @Test
     @MainActor
     func testSuccessfulLogin() async throws {
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
                 try view.find(button: "Next").tap()
                 try view.find(ViewType.SecureField.self).setInput("valid-token")
-                try view.find(button: "Sign In").tap()
+                try await view.actualView().submit()
 
                 #expect(session.hasSession)
             }
diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift
index cab9a8f..eb378ee 100644
--- a/Coder Desktop/Coder DesktopTests/Util.swift	
+++ b/Coder Desktop/Coder DesktopTests/Util.swift	
@@ -47,7 +47,7 @@ class MockSession: Session {
 struct MockClient: Client {
     init(url _: URL, token _: String? = nil) {}
 
-    func user(_: String) async throws -> Coder_Desktop.User {
+    func user(_: String) async throws(ClientError) -> Coder_Desktop.User {
         User(
             id: UUID(),
             username: "admin",
@@ -68,9 +68,9 @@ struct MockClient: Client {
 
 struct MockErrorClient: Client {
     init(url: URL, token: String?) {}
-    func user(_ ident: String) async throws -> Coder_Desktop.User {
+    func user(_ ident: String) async throws(ClientError) -> Coder_Desktop.User {
         throw ClientError.badResponse
     }
 }
 
-extension Inspection: @retroactive InspectionEmissary { }
+extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary { }
diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
index f7d482c..fb975e0 100644
--- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift	
+++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift	
@@ -22,7 +22,7 @@ struct VPNMenuTests {
     func testVPNLoggedOut() async throws {
         session.hasSession = false
 
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 let toggle = try view.find(ViewType.Toggle.self)
                 #expect(toggle.isDisabled())
@@ -35,7 +35,7 @@ struct VPNMenuTests {
     @Test
     @MainActor
     func testStartStopCalled() async throws {
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 var toggle = try view.find(ViewType.Toggle.self)
                 #expect(try !toggle.isOn())
@@ -62,7 +62,7 @@ struct VPNMenuTests {
     func testVPNDisabledWhileConnecting() async throws {
         vpn.state = .disabled
 
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 var toggle = try view.find(ViewType.Toggle.self)
                 #expect(try !toggle.isOn())
@@ -83,7 +83,7 @@ struct VPNMenuTests {
     func testVPNDisabledWhileDisconnecting() async throws {
         vpn.state = .disabled
 
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 var toggle = try view.find(ViewType.Toggle.self)
                 #expect(try !toggle.isOn())
@@ -108,7 +108,7 @@ struct VPNMenuTests {
     @Test
     @MainActor
     func testOffWhenFailed() async throws {
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 let toggle = try view.find(ViewType.Toggle.self)
                 #expect(try !toggle.isOn())
diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
index 5a2c960..c5a167e 100644
--- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift	
+++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift	
@@ -20,7 +20,7 @@ struct VPNStateTests {
     func testDisabledState() async throws {
         vpn.state = .disabled
 
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 #expect(throws: Never.self) {
                     try view.find(text: "Enable CoderVPN to see agents")
@@ -34,7 +34,7 @@ struct VPNStateTests {
     func testConnectingState() async throws {
         vpn.state = .connecting
 
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 let progressView = try view.find(ViewType.ProgressView.self)
                 #expect(try progressView.labelView().text().string() == "Starting CoderVPN...")
@@ -47,7 +47,7 @@ struct VPNStateTests {
     func testDisconnectingState() async throws {
         vpn.state = .disconnecting
 
-        try await ViewHosting.host(view) { _ in
+        try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
                 let progressView = try view.find(ViewType.ProgressView.self)
                 #expect(try progressView.labelView().text().string() == "Stopping CoderVPN...")
@@ -60,7 +60,7 @@ struct VPNStateTests {
     func testFailedState() async throws {
         vpn.state = .failed(.exampleError)
 
-        try await ViewHosting.host(view.environmentObject(vpn)) { _ in
+        try await ViewHosting.host(view.environmentObject(vpn)) {
             try await sut.inspection.inspect { view in
                 let text = try view.find(ViewType.Text.self)
                 #expect(try text.string() == VPNServiceError.exampleError.description)
@@ -73,7 +73,7 @@ struct VPNStateTests {
     func testDefaultState() async throws {
         vpn.state = .connected
 
-        try await ViewHosting.host(view.environmentObject(vpn)) { _ in
+        try await ViewHosting.host(view.environmentObject(vpn)) {
             try await sut.inspection.inspect { view in
                 #expect(throws: (any Error).self) {
                     _ = try view.find(ViewType.Text.self)

From a4ca27e37c797443c2fc7f40fd63ea270c9d9911 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Mon, 16 Dec 2024 21:59:56 +1100
Subject: [PATCH 2/2] review

---
 .../Preview Content/PreviewClient.swift       |  3 +-
 Coder Desktop/Coder Desktop/SDK/Client.swift  | 37 ++++++++-----------
 Coder Desktop/Coder Desktop/SDK/User.swift    |  5 +--
 Coder Desktop/Coder DesktopTests/Util.swift   |  2 +-
 4 files changed, 21 insertions(+), 26 deletions(-)

diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift
index 592fa83..336df0c 100644
--- a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift	
+++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift	
@@ -1,4 +1,5 @@
 import SwiftUI
+import Alamofire
 
 struct PreviewClient: Client {
     init(url _: URL, token _: String? = nil) {}
@@ -22,7 +23,7 @@ struct PreviewClient: Client {
                 roles: []
             )
         } catch {
-            throw ClientError.badResponse
+            throw ClientError.reqError(AFError.explicitlyCancelled)
         }
     }
 }
diff --git a/Coder Desktop/Coder Desktop/SDK/Client.swift b/Coder Desktop/Coder Desktop/SDK/Client.swift
index 6058351..8f5e2c0 100644
--- a/Coder Desktop/Coder Desktop/SDK/Client.swift	
+++ b/Coder Desktop/Coder Desktop/SDK/Client.swift	
@@ -35,14 +35,11 @@ struct CoderClient: Client {
             parameters: body,
             headers: headers
         ).serializingData().response
-        guard let response = out.response else {
-            throw ClientError.noResponse
-        }
         switch out.result {
         case .success(let data):
-            return HTTPResponse(resp: response, data: data, req: out.request)
-        case .failure:
-            throw ClientError.badResponse
+            return HTTPResponse(resp: out.response!, data: data, req: out.request)
+        case .failure(let error):
+            throw ClientError.reqError(error)
         }
     }
 
@@ -57,28 +54,26 @@ struct CoderClient: Client {
             method: method,
             headers: headers
         ).serializingData().response
-        guard let response = out.response else {
-            throw ClientError.noResponse
-        }
         switch out.result {
         case .success(let data):
-            return HTTPResponse(resp: response, data: data, req: out.request)
-        case .failure:
-            throw ClientError.badResponse
+            return HTTPResponse(resp: out.response!, data: data, req: out.request)
+        case .failure(let error):
+            throw ClientError.reqError(error)
         }
     }
 
-    func responseAsError(_ resp: HTTPResponse) throws(ClientError) -> APIError {
+    func responseAsError(_ resp: HTTPResponse) -> ClientError {
         do {
             let body = try CoderClient.decoder.decode(Response.self, from: resp.data)
-            return APIError(
+            let out = APIError(
                 response: body,
                 statusCode: resp.resp.statusCode,
                 method: resp.req?.httpMethod,
                 url: resp.req?.url
             )
+            return ClientError.apiError(out)
         } catch {
-            throw ClientError.badResponse
+            return ClientError.unexpectedResponse(resp.data[...1024])
         }
     }
 
@@ -130,17 +125,17 @@ struct ValidationError: Decodable {
 
 enum ClientError: Error {
     case apiError(APIError)
-    case badResponse
-    case noResponse
+    case reqError(AFError)
+    case unexpectedResponse(Data)
 
     var description: String {
         switch self {
         case .apiError(let error):
             return error.description
-        case .badResponse:
-            return "Bad response"
-        case .noResponse:
-            return "No response"
+        case .reqError(let error):
+            return error.localizedDescription
+        case .unexpectedResponse(let data):
+            return "Unexpected response: \(data)"
         }
     }
 }
diff --git a/Coder Desktop/Coder Desktop/SDK/User.swift b/Coder Desktop/Coder Desktop/SDK/User.swift
index 789c34f..f9f20fa 100644
--- a/Coder Desktop/Coder Desktop/SDK/User.swift	
+++ b/Coder Desktop/Coder Desktop/SDK/User.swift	
@@ -4,13 +4,12 @@ extension CoderClient {
     func user(_ ident: String) async throws(ClientError) -> User {
         let res = try await request("/api/v2/users/\(ident)", method: .get)
         guard res.resp.statusCode == 200 else {
-            let error = try responseAsError(res)
-            throw ClientError.apiError(error)
+            throw responseAsError(res)
         }
         do {
             return try CoderClient.decoder.decode(User.self, from: res.data)
         } catch {
-            throw ClientError.badResponse
+            throw ClientError.unexpectedResponse(res.data[...1024])
         }
     }
 }
diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift
index eb378ee..f902dce 100644
--- a/Coder Desktop/Coder DesktopTests/Util.swift	
+++ b/Coder Desktop/Coder DesktopTests/Util.swift	
@@ -69,7 +69,7 @@ struct MockClient: Client {
 struct MockErrorClient: Client {
     init(url: URL, token: String?) {}
     func user(_ ident: String) async throws(ClientError) -> Coder_Desktop.User {
-        throw ClientError.badResponse
+        throw ClientError.reqError(.explicitlyCancelled)
     }
 }