fix(conversation-assistant): improve error handling and sync resilience

- 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 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-30 03:34:41 -08:00
parent 36218560a0
commit 86b4c43e09
2 changed files with 103 additions and 59 deletions

View file

@ -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)")
}
}

View file

@ -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")