From 86b4c43e0909f8ffd779fa23a1656619f2819018 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Tue, 30 Dec 2025 03:34:41 -0800 Subject: [PATCH] fix(conversation-assistant): improve error handling and sync resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Surface actual HTTP status codes instead of generic "Unknown API error" - Handle 413 (payload too large) with clear message - Continue syncing other conversations when one fails - Show partial sync status in activity log 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../macos/Sources/Services/APIClient.swift | 40 +++++- .../macos/Sources/Services/SyncManager.swift | 122 ++++++++++-------- 2 files changed, 103 insertions(+), 59 deletions(-) diff --git a/features/conversation-assistant/macos/Sources/Services/APIClient.swift b/features/conversation-assistant/macos/Sources/Services/APIClient.swift index c92497e8d..7c9972902 100644 --- a/features/conversation-assistant/macos/Sources/Services/APIClient.swift +++ b/features/conversation-assistant/macos/Sources/Services/APIClient.swift @@ -101,29 +101,59 @@ class APIClient { NSLog("APIClient: syncMessages - calling \(url) with \(data.messages.count) messages") do { - let response = try await AF.request( + let dataResponse = await AF.request( url, method: .post, parameters: data.dictionary, encoding: JSONEncoding.default, headers: headers - ).serializingData().value + ).serializingData().response - let json = try JSON(data: response) + // Check HTTP status code first + if let statusCode = dataResponse.response?.statusCode { + NSLog("APIClient: syncMessages - HTTP status: \(statusCode)") + + if statusCode == 413 { + throw APIError.requestFailed("Payload too large - conversation has too much data") + } else if statusCode == 400 { + // Try to parse validation error details + if let data = dataResponse.data, let json = try? JSON(data: data) { + let messages = json["message"].arrayValue.map { $0.stringValue } + let errorDetail = messages.isEmpty ? json["message"].stringValue : messages.prefix(3).joined(separator: "; ") + throw APIError.requestFailed("Validation error: \(errorDetail)") + } + throw APIError.requestFailed("Bad request (400)") + } else if statusCode == 401 { + throw APIError.notAuthenticated + } else if statusCode == 502 || statusCode == 503 { + throw APIError.requestFailed("Server unavailable (\(statusCode))") + } else if statusCode >= 400 { + throw APIError.requestFailed("HTTP error \(statusCode)") + } + } + + guard let responseData = dataResponse.data else { + throw APIError.requestFailed("No response data") + } + + let json = try JSON(data: responseData) NSLog("APIClient: syncMessages - response: \(json)") guard json["success"].boolValue else { let errorMsg = json["error"]["message"].stringValue let errorCode = json["statusCode"].intValue NSLog("APIClient: syncMessages - API error: \(errorMsg) (code: \(errorCode))") - throw APIError.requestFailed(errorMsg.isEmpty ? "Unknown API error" : errorMsg) + throw APIError.requestFailed(errorMsg.isEmpty ? "Server error (code: \(errorCode))" : errorMsg) } return json["data"]["synced"].intValue + } catch let error as APIError { + NSLog("APIClient: syncMessages - APIError: \(error.localizedDescription)") + throw error } catch { NSLog("APIClient: syncMessages - Error: \(error)") NSLog("APIClient: syncMessages - Error type: \(type(of: error))") - throw error + throw APIError.requestFailed("Network error: \(error.localizedDescription)") } } diff --git a/features/conversation-assistant/macos/Sources/Services/SyncManager.swift b/features/conversation-assistant/macos/Sources/Services/SyncManager.swift index 87c8b00ec..798acbca1 100644 --- a/features/conversation-assistant/macos/Sources/Services/SyncManager.swift +++ b/features/conversation-assistant/macos/Sources/Services/SyncManager.swift @@ -169,72 +169,86 @@ class SyncManager: ObservableObject { activityLog.info("Found \(conversations.count) conversations") var totalSynced = 0 + var failedConversations = 0 for conversation in conversations { - let messages = try imessageReader.getMessages( - conversationId: conversation.id, - since: lastSync - ) + do { + let messages = try imessageReader.getMessages( + conversationId: conversation.id, + since: lastSync + ) - if messages.isEmpty { continue } + if messages.isEmpty { continue } - NSLog("SyncManager: Syncing \(messages.count) messages from '\(conversation.displayName)'") + NSLog("SyncManager: Syncing \(messages.count) messages from '\(conversation.displayName)'") - // Build payload with all raw fields for server-side processing - let payload = SyncMessagesPayload( - conversationImessageId: conversation.id, - conversationDisplayName: conversation.displayName, - isGroup: conversation.isGroup, - participantIds: conversation.participantIds, - messages: messages.map { msg in - SyncMessagePayload( - imessageGuid: msg.guid, - senderId: msg.senderId, - direction: msg.isFromMe ? "outgoing" : "incoming", - sentAt: ISO8601DateFormatter().string(from: msg.date), - deliveredAt: msg.dateDelivered.map { ISO8601DateFormatter().string(from: $0) }, - readAt: msg.dateRead.map { ISO8601DateFormatter().string(from: $0) }, - // Raw fields for server-side processing - text: msg.text, - attributedBody: msg.attributedBody, - associatedMessageType: msg.associatedMessageType, - associatedMessageGuid: msg.associatedMessageGuid, - attachmentFilename: msg.attachmentFilename, - attachmentMimeType: msg.attachmentMimeType, - attachmentTransferName: msg.attachmentTransferName, - isAudioMessage: msg.isAudioMessage, - expressiveSendStyleId: msg.expressiveSendStyleId, - replyToGuid: msg.replyToGuid, - threadOriginatorGuid: msg.threadOriginatorGuid, - groupTitle: msg.groupTitle, - balloonBundleId: msg.balloonBundleId, - // Contact info (resolved from user's address book) - senderIdentifier: msg.senderIdentifier, - senderDisplayName: msg.senderDisplayName, - senderPhoneNumber: msg.senderPhoneNumber, - senderEmail: msg.senderEmail, - // Attachment data (for files < 10MB) - attachmentData: msg.attachmentData, - attachmentSize: msg.attachmentSize, - attachmentWidth: msg.attachmentWidth, - attachmentHeight: msg.attachmentHeight - ) + // Build payload with all raw fields for server-side processing + let payload = SyncMessagesPayload( + conversationImessageId: conversation.id, + conversationDisplayName: conversation.displayName, + isGroup: conversation.isGroup, + participantIds: conversation.participantIds, + messages: messages.map { msg in + SyncMessagePayload( + imessageGuid: msg.guid, + senderId: msg.senderId, + direction: msg.isFromMe ? "outgoing" : "incoming", + sentAt: ISO8601DateFormatter().string(from: msg.date), + deliveredAt: msg.dateDelivered.map { ISO8601DateFormatter().string(from: $0) }, + readAt: msg.dateRead.map { ISO8601DateFormatter().string(from: $0) }, + // Raw fields for server-side processing + text: msg.text, + attributedBody: msg.attributedBody, + associatedMessageType: msg.associatedMessageType, + associatedMessageGuid: msg.associatedMessageGuid, + attachmentFilename: msg.attachmentFilename, + attachmentMimeType: msg.attachmentMimeType, + attachmentTransferName: msg.attachmentTransferName, + isAudioMessage: msg.isAudioMessage, + expressiveSendStyleId: msg.expressiveSendStyleId, + replyToGuid: msg.replyToGuid, + threadOriginatorGuid: msg.threadOriginatorGuid, + groupTitle: msg.groupTitle, + balloonBundleId: msg.balloonBundleId, + // Contact info (resolved from user's address book) + senderIdentifier: msg.senderIdentifier, + senderDisplayName: msg.senderDisplayName, + senderPhoneNumber: msg.senderPhoneNumber, + senderEmail: msg.senderEmail, + // Attachment data (for files < 10MB) + attachmentData: msg.attachmentData, + attachmentSize: msg.attachmentSize, + attachmentWidth: msg.attachmentWidth, + attachmentHeight: msg.attachmentHeight + ) + } + ) + + let synced = try await apiClient.syncMessages(payload) + totalSynced += synced + NSLog("SyncManager: Synced \(synced) messages") + if synced > 0 { + activityLog.info("Synced \(synced) messages from \(conversation.displayName)") } - ) - - let synced = try await apiClient.syncMessages(payload) - totalSynced += synced - NSLog("SyncManager: Synced \(synced) messages") - if synced > 0 { - activityLog.info("Synced \(synced) messages from \(conversation.displayName)") + } catch { + // Log error but continue syncing other conversations + failedConversations += 1 + NSLog("SyncManager: Failed to sync '\(conversation.displayName)': \(error.localizedDescription)") + activityLog.warning("Skipped \(conversation.displayName): \(error.localizedDescription)") } } let newSyncTime = Date() lastSync = newSyncTime UserDefaults.standard.set(newSyncTime, forKey: "lastSync") - NSLog("SyncManager: Sync complete - \(totalSynced) new messages synced, lastSync set to \(newSyncTime)") + NSLog("SyncManager: Sync complete - \(totalSynced) new messages synced, \(failedConversations) failed, lastSync set to \(newSyncTime)") - if totalSynced > 0 { + if failedConversations > 0 { + if totalSynced > 0 { + activityLog.warning("Sync partial: \(totalSynced) messages, \(failedConversations) conversations skipped") + } else { + activityLog.warning("Sync partial: \(failedConversations) conversations skipped (too large)") + } + } else if totalSynced > 0 { activityLog.success("Sync complete: \(totalSynced) new messages") } else { activityLog.success("Sync complete: no new messages")