Testing Your iCloud Code By Simulating CloudKit Errors

We're currently working on integrating iCloud support into Streaks, which means all of your tasks and task data will be in sync across all devices (as well as immediately available when you install Streaks on a new device).

Unfortunately, there are many ways that iCloud can fail, such as:

  • Network not available
  • iCloud servers are down
  • Trying to insert a duplicate record

In order to test the robustness of the system, we wanted to introduce these errors into the system to see how well it can recover.

We could test some basic kinds errors manually, such as disconnecting Wi-Fi to simulate "network not available" type errors, but we wanted to automate this.


In order to do this, we introduced a layer inbetween the sync code and the iCloud code, which would return errors at a prescribed error rate.

Normally, to execute a basic query, you would do something as follows:

let container = CKContainer(identifier: "some-id")
let database  = container.privateCloudDatabase

let query: CKQuery = ...

database.perform(query, inZoneWith: nil) { records, error in
    if let error = error {
        // Handle the error
    }
}

Instead, we now do the following:

let ckContainer = CKContainer(identifier: "some-id")

let container = SyncContainer(container: cKcontainer)

let query: CKQuery = ...

container.perform(query, inZoneWith: nil) { records, error in
    if let error = error {
        // Handle the error
    }

}

There's nothing magic about the SyncContainer class - it is purely a wrapper around CKContainer and CKDatabase, along with a random number generator that decides whether or not to return an error.

We use the following variables to control error simulation. In this case the error rate is set to 0%, meaning no fake errors will occur (in other words, if an error does occur, it is real).

class SyncContainer {
    /// The frequency that a simulated error will occur (0-1)
    private var simulateErrorRate: Float = 0
    
    /// How long to wait before returning a simulated error
    private let simulateErrorDelay: TimeInterval = 1
    
    /// The types of errors that can be simulated
    private let simulateErrorsPossibleCodes: [CKError.Code] = [
        .internalError,
        .zoneBusy,
        .userDeletedZone,
        .zoneNotFound,
        .invalidArguments,
        .networkFailure,
        .networkUnavailable,
        .permissionFailure,
        .quotaExceeded,
        .requestRateLimited,
        .serviceUnavailable
    ]

    func enableSimulatedErrors(errorRate: Float) {
        self.simulateErrorRate = max(0, min(1, errorRate))
    }
}

The error rate can be controlled with enabledSimulatedErrors(). A value of 1 indicates that every request will fail with one of the above error codes.


Returning back to the perform() example above, this method now looks like the following:

class SyncContainer {
    func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZoneID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) {
        if shouldThrowError() {
            self.delay(self.simulateErrorDelay) {
                completionHandler(nil, self.createRandomError())
            }
        }
        else {
            self.database.perform(
                query, 
                inZoneWith: zoneID, 
                completionHandler: completionHandler
            )
        }
    }
}

This wrapper calls shouldThrowError(), which checks whether or not an error should occur, based on the simulateErrorRate value. If so, the completion is delayed, before a random error is returned.


Here's the full code. Note that handling CKDatabaseOperation calls is the trickiest, as there are several different operation types, each of which has different blocks for returning errors (they all share the completionBlock though!).

import CloudKit

class SyncContainer {
    private let container: CKContainer
    
    fileprivate var database: CKDatabase {
        return self.container.privateCloudDatabase
    }

    /// The frequency that a simulated error will occur (0-1)
    private var simulateErrorRate: Float = 0
    
    /// How long to wait before returning a simulated error
    private let simulateErrorDelay: TimeInterval = 1
    
    /// The types of errors that can be simulated
    private let simulateErrorsPossibleCodes: [CKError.Code] = [
        .internalError,
        .zoneBusy,
        .userDeletedZone,
        .zoneNotFound,
        .invalidArguments,
        .networkFailure,
        .networkUnavailable,
        .permissionFailure,
        .quotaExceeded,
        .requestRateLimited,
        .serviceUnavailable
    ]

    init(container: CKContainer) {
        self.container = container
    }

    /// Used for delaying simulated errors
    private func delay(_ delay: TimeInterval, _ closure: @escaping () -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closure)
    }

    func save(_ record: CKRecord, completionHandler: @escaping (CKRecord?, Error?) -> Void) {
        if shouldThrowError() {
            delay(self.simulateErrorDelay) {
                completionHandler(nil, self.createRandomError())
            }
        }
        else {
            self.database.save(record, completionHandler: completionHandler)
        }
    }

    func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZoneID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) {
        if shouldThrowError() {
            self.delay(self.simulateErrorDelay) {
                completionHandler(nil, self.createRandomError())
            }
        }
        else {
            self.database.perform(query, inZoneWith: zoneID, completionHandler: completionHandler)
        }
    }
    
    func add(_ operation: CKDatabaseOperation) {
        if shouldThrowError() {
            self.delay(self.simulateErrorDelay) {
                let error = self.createRandomError()
                
                if let operation = operation as? CKModifyRecordsOperation {
                    operation.modifyRecordsCompletionBlock?(nil, nil, error)
                }
                else if let operation = operation as? CKModifyRecordZonesOperation {
                    operation.modifyRecordZonesCompletionBlock?(nil, nil, error)
                }
                else if let operation = operation as? CKModifySubscriptionsOperation {
                    operation.modifySubscriptionsCompletionBlock?(nil, nil, error)
                }
                else if let operation = operation as? CKQueryOperation {
                    operation.queryCompletionBlock?(nil, error)
                }
                else {
                    if #available(iOS 10.0, *) {
                        if let operation = operation as? CKFetchRecordZoneChangesOperation {
                            if let options = operation.optionsByRecordZoneID {
                                for (zoneId, _) in options {
                                    operation.recordZoneFetchCompletionBlock?(zoneId, nil, nil, false, error)
                                }
                            }
                            
                            operation.fetchRecordZoneChangesCompletionBlock?(error)
                        }
                    }
                }
                
                operation.completionBlock?()
            }
        }
        else {
            self.database.add(operation)
        }
    }
    
    func fetchAllRecordZones(completionHandler: @escaping ([CKRecordZone]?, Error?) -> Void) {
        if shouldThrowError() {
            self.delay(self.simulateErrorDelay) {
                completionHandler(nil, self.createRandomError())
            }
        }
        else {
            self.database.fetchAllRecordZones(completionHandler: completionHandler)
        }
    }
    
    func fetch(withRecordID recordID: CKRecordID, completionHandler: @escaping (CKRecord?, Error?) -> Void) {
        if shouldThrowError() {
            self.delay(self.simulateErrorDelay) {
                completionHandler(nil, self.createRandomError())
            }
        }
        else {
            self.database.fetch(withRecordID: recordID, completionHandler: completionHandler)
        }
    }
    
    func fetchAllSubscriptions(completionHandler: @escaping ([CKSubscription]?, Error?) -> Void) {
        if shouldThrowError() {
            self.delay(self.simulateErrorDelay) {
                completionHandler(nil, self.createRandomError())
            }
        }
        else {
            self.database.fetchAllSubscriptions(completionHandler: completionHandler)
        }
    }
}

extension SyncContainer {
    func enableSimulatedErrors(errorRate: Float) {
        self.simulateErrorRate = max(0, min(1, errorRate))
    }

    private func shouldThrowError() -> Bool {
        guard self.simulateErrorRate > 0 else {
            return false
        }

        let rand = Float(arc4random()) / Float(UInt32.max)
        return rand < self.simulateErrorRate
    }
    
    fileprivate func createRandomError(_ additionalCodes: [CKError.Code] = []) -> CKError {
        let errors: [CKError.Code] = simulateErrorsPossibleCodes + additionalCodes
        return createError(code: errors.randomElement()!)
    }
    
    fileprivate func createError(code: CKError.Code) -> CKError {
        let error = NSError(domain: CKErrorDomain, code: code.rawValue, userInfo: nil)
        return CKError(_nsError: error)
    }
}

Hopefully you find this useful, and look out for the update to Streaks very soon!