Skip to content

SyncEngine drops records on transient CloudKit errors (serviceUnavailable, requestRateLimited) instead of re-queuing #432

@mario-luis-dev

Description

@mario-luis-dev

Description

When CloudKit returns transient errors like .serviceUnavailable (CKError 6/2009) or .requestRateLimited (CKError 7/2062)for individual record saves, the SyncEngine silently drops those records from the sync queue. They are never retried, causing permanent data loss from the sync perspective.

Location: SyncEngine.swift around line 1784 (v1.6.1, commit da3a94e)

  case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable,                                               
    .notAuthenticated, .operationCancelled,                                                                                
    .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement,                              
    .invalidArguments, .resultsTruncated, .assetFileNotFound,                                                              
    .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired,                                   
    .badDatabase, .quotaExceeded, .limitExceeded, .userDeletedZone, .tooManyParticipants,                                  
    .alreadyShared, .managedAccountRestricted, .participantMayNeedVerification,                                            
    .serverResponseLost, .assetNotAvailable, .accountTemporarilyUnavailable:                                               
    continue   // ← record is NOT re-queued       

Compare with .batchRequestFailed at line 1781 which correctly re-queues:

  case .batchRequestFailed:                             
    newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))                                                 
    break

The same pattern exists for failed record deletes (around line 1820).

Steps to reproduce

  1. Register multiple tables with SyncEngine (in my case ~25 tables including junction tables)
  2. Save a record that triggers writes across several tables (e.g., a parent record + genres + credits via junction
    tables)
  3. CloudKit starts rate-limiting due to the burst of CKRecord operations
  4. Observe that throttled records are never synced

Console output:
"
Caught error: <CKError 0x10b6fccc0: "Service Unavailable" (6/2009); Retry after 13.0 seconds; container ID = container_id>: SQLiteData CloudKit Failure

SQLiteData (private.db) willFetchRecordZoneChanges
✅ Zone: co.pointfree.SQLiteData.defaultZone:defaultOwner
❌ requestRateLimited

error fetching changes for context :
<CKError 0x1313b4330: "Request Rate Limited" (7/2062);
"Operation throttled by previous server http 429 reply. Retry after 11.0 seconds.">;
"

Checklist

  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • I have determined whether this bug is also reproducible in a vanilla GRDB project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

Expected behavior

Transient errors (at minimum .serviceUnavailable, .requestRateLimited, .zoneBusy, .networkFailure, .networkUnavailable) should re-queue the failed record so CKSyncEngine can retry it, similar to how .batchRequestFailed is handled:

  case .serviceUnavailable, .requestRateLimited, .zoneBusy,
       .networkFailure, .networkUnavailable:                                                                               
    newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))

Actual behavior

The continue skips the record without appending it to newPendingRecordZoneChanges. Since the defer block only adds records in that array back to syncEngine.state, the dropped records are permanently lost from the sync queue.

Reproducing project

In apps with many related tables, a single save operation (e.g., movie + 5 genres + 10 credits = 16 records) easily triggers CloudKit rate limiting. Once throttling begins, it cascades — more records get dropped, more retries fail, and data never syncs. Calling syncEngine.syncChanges() doesn't help because the records are no longer tracked as pending.

SQLiteData version information

1.6.1 (da3a94e)

Sharing version information

2.7.4

GRDB version information

7.10.0

Destination operating system

iOS 26

Xcode version information

26.4

Swift Compiler version information

Swift 6

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions