Handling CloudKit Errors - ios

I am looking for general advice on handling CloudKit errors in Swift and am having trouble finding good examples online. Here are the things I'm wondering:
1) Should I account for every single error type each time the possibility for an error arises, or is that not really necessary?
2) I have read that one common way of handling CloudKit errors is by retrying to execute the operation after the time interval the error message provides. Should this retry basically be my standard procedure for all errors?
3) Do different CloudKit operations (save, fetch, etc.) produce different types of errors, or is there one standard set of CloudKit errors?
Thanks in advance! I'm just looking for general info on how to go about tackling error handling with CloudKit because I'm not really sure where to start.

Yes, you want to check every cloudkit call for errors. Apple stresses this point in the cloudkit-related WWDC videos.
What you do when you detect an error varies greatly. Retry is sometimes an option, but sometimes not appropriate. If you're using batch operations, retrying may require some additional work to extract the just the records that failed. So, yes you may sometimes want to retry, but no, you probably won't automatically retry every operation that fails.
There is one set of errors, defined in CKError.h. But, you don't always just get a CKError. Sometimes, especially with CKErrorPartialFailure, you get a top-level error that contains nested errors that you also have to unwrap. As of IOS 10, the list of errors in CKError.h looks like:
typedef NS_ENUM(NSInteger, CKErrorCode) {
CKErrorInternalError = 1, /* CloudKit.framework encountered an error. This is a non-recoverable error. */
CKErrorPartialFailure = 2, /* Some items failed, but the operation succeeded overall. Check CKPartialErrorsByItemIDKey in the userInfo dictionary for more details. */
CKErrorNetworkUnavailable = 3, /* Network not available */
CKErrorNetworkFailure = 4, /* Network error (available but CFNetwork gave us an error) */
CKErrorBadContainer = 5, /* Un-provisioned or unauthorized container. Try provisioning the container before retrying the operation. */
CKErrorServiceUnavailable = 6, /* Service unavailable */
CKErrorRequestRateLimited = 7, /* Client is being rate limited */
CKErrorMissingEntitlement = 8, /* Missing entitlement */
CKErrorNotAuthenticated = 9, /* Not authenticated (writing without being logged in, no user record) */
CKErrorPermissionFailure = 10, /* Access failure (save, fetch, or shareAccept) */
CKErrorUnknownItem = 11, /* Record does not exist */
CKErrorInvalidArguments = 12, /* Bad client request (bad record graph, malformed predicate) */
CKErrorResultsTruncated NS_DEPRECATED(10_10, 10_12, 8_0, 10_0, "Will not be returned") = 13,
CKErrorServerRecordChanged = 14, /* The record was rejected because the version on the server was different */
CKErrorServerRejectedRequest = 15, /* The server rejected this request. This is a non-recoverable error */
CKErrorAssetFileNotFound = 16, /* Asset file was not found */
CKErrorAssetFileModified = 17, /* Asset file content was modified while being saved */
CKErrorIncompatibleVersion = 18, /* App version is less than the minimum allowed version */
CKErrorConstraintViolation = 19, /* The server rejected the request because there was a conflict with a unique field. */
CKErrorOperationCancelled = 20, /* A CKOperation was explicitly cancelled */
CKErrorChangeTokenExpired = 21, /* The previousServerChangeToken value is too old and the client must re-sync from scratch */
CKErrorBatchRequestFailed = 22, /* One of the items in this batch operation failed in a zone with atomic updates, so the entire batch was rejected. */
CKErrorZoneBusy = 23, /* The server is too busy to handle this zone operation. Try the operation again in a few seconds. */
CKErrorBadDatabase = 24, /* Operation could not be completed on the given database. Likely caused by attempting to modify zones in the public database. */
CKErrorQuotaExceeded = 25, /* Saving a record would exceed quota */
CKErrorZoneNotFound = 26, /* The specified zone does not exist on the server */
CKErrorLimitExceeded = 27, /* The request to the server was too large. Retry this request as a smaller batch. */
CKErrorUserDeletedZone = 28, /* The user deleted this zone through the settings UI. Your client should either remove its local data or prompt the user before attempting to re-upload any data to this zone. */
CKErrorTooManyParticipants NS_AVAILABLE(10_12, 10_0) = 29, /* A share cannot be saved because there are too many participants attached to the share */
CKErrorAlreadyShared NS_AVAILABLE(10_12, 10_0) = 30, /* A record/share cannot be saved, doing so would cause a hierarchy of records to exist in multiple shares */
CKErrorReferenceViolation NS_AVAILABLE(10_12, 10_0) = 31, /* The target of a record's parent or share reference was not found */
CKErrorManagedAccountRestricted NS_AVAILABLE(10_12, 10_0) = 32, /* Request was rejected due to a managed account restriction */
CKErrorParticipantMayNeedVerification NS_AVAILABLE(10_12, 10_0) = 33, /* Share Metadata cannot be determined, because the user is not a member of the share. There are invited participants on the share with email addresses or phone numbers not associated with any iCloud account. The user may be able to join the share if they can associate one of those email addresses or phone numbers with their iCloud account via the system Share Accept UI. Call UIApplication's openURL on this share URL to have the user attempt to verify their information. */
} NS_ENUM_AVAILABLE(10_10, 8_0);
One approach, while you're developing and testing the app, is to check every cloudkit operation for an error, and if detected, fire an NSAssert to stop the app. Then, examine the error, underlying errors and context to determine why it failed and what you need to do about it. Most likely, over time, you'll see common patterns emerge and you can then consider building a generic error handler.

I've written a CloudKit help that makes it much easier to process errors. This is just a starting point and there is a lot more that can be done.
The main focus of this helper, in its current state, is to make it easy to retry errors that should be retried after an appropriate timeout.
But you still need to deal with errors that shouldn't be retried such as the user's iCloud storage being full. Even with this helper, every call to one of these helper methods needs to properly handle the result and possibly report an error to the user. Of course you can add a help method that checks all of the possible error types and shows an appropriate message. Then all uses of the CloudKit code can call that one helper method.
This also only covers a few of the possible operations. You would want to add support for other operations as well. Lastly, this doesn't handle partial errors yet. That would be another useful enhancement.
import Foundation
import CloudKit
public class CloudKitHelper {
private static func determineRetry(error: Error) -> Double? {
if let ckerror = error as? CKError {
switch ckerror {
case CKError.requestRateLimited, CKError.serviceUnavailable, CKError.zoneBusy, CKError.networkFailure:
let retry = ckerror.retryAfterSeconds ?? 3.0
return retry
default:
return nil
}
} else {
let nserror = error as NSError
if nserror.domain == NSCocoaErrorDomain {
if nserror.code == 4097 {
print("cloudd is dead")
return 6.0
}
}
print("Unexpected error: \(error)")
}
return nil
}
public static func modifyRecordZonesOperation(database: CKDatabase, recordZonesToSave: [CKRecordZone]?, recordZoneIDsToDelete: [CKRecordZoneID]?, modifyRecordZonesCompletionBlock: #escaping (([CKRecordZone]?, [CKRecordZoneID]?, Error?) -> Void)) {
let op = CKModifyRecordZonesOperation(recordZonesToSave: recordZonesToSave, recordZoneIDsToDelete: recordZoneIDsToDelete)
op.modifyRecordZonesCompletionBlock = { (savedRecordZones: [CKRecordZone]?, deletedRecordZoneIDs: [CKRecordZoneID]?, error: Error?) -> Void in
if let error = error {
if let delay = determineRetry(error: error) {
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
CloudKitHelper.modifyRecordZonesOperation(database: database, recordZonesToSave: recordZonesToSave, recordZoneIDsToDelete: recordZoneIDsToDelete, modifyRecordZonesCompletionBlock: modifyRecordZonesCompletionBlock)
}
} else {
modifyRecordZonesCompletionBlock(savedRecordZones, deletedRecordZoneIDs, error)
}
} else {
modifyRecordZonesCompletionBlock(savedRecordZones, deletedRecordZoneIDs, error)
}
}
database.add(op)
}
public static func modifyRecords(database: CKDatabase, records: [CKRecord], completion: #escaping (([CKRecord]?, Error?) -> Void)) {
CloudKitHelper.modifyAndDeleteRecords(database: database, records: records, recordIDs: nil) { (savedRecords, deletedRecords, error) in
completion(savedRecords, error)
}
}
public static func deleteRecords(database: CKDatabase, recordIDs: [CKRecordID], completion: #escaping (([CKRecordID]?, Error?) -> Void)) {
CloudKitHelper.modifyAndDeleteRecords(database: database, records: nil, recordIDs: recordIDs) { (savedRecords, deletedRecords, error) in
completion(deletedRecords, error)
}
}
public static func modifyAndDeleteRecords(database: CKDatabase, records: [CKRecord]?, recordIDs: [CKRecordID]?, completion: #escaping (([CKRecord]?, [CKRecordID]?, Error?) -> Void)) {
let op = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: recordIDs)
op.savePolicy = .allKeys
op.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, deletedRecordIDs: [CKRecordID]?, error: Error?) -> Void in
if let error = error {
if let delay = determineRetry(error: error) {
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
CloudKitHelper.modifyAndDeleteRecords(database: database, records: records, recordIDs: recordIDs, completion: completion)
}
} else {
completion(savedRecords, deletedRecordIDs, error)
}
} else {
completion(savedRecords, deletedRecordIDs, error)
}
}
database.add(op)
}
}

Related

How to deactivate on demand connect VPN from network extension?

I have configured an always on VPN with a NEOnDemandRuleConnect I retrieve some user data from a backend such as expiration date if the user has paid the subscription. If it expires I'd like to deactivate the VPN without opening the main app, doing it from the Network Extension. I retrieve the data from the backend using a daily timer and then check if the subscription has expired. Then I'd have a function that loads the VPN manager from the system settings app and then deactivate it and finally save it. If I don't deactivate the manager the device will be without connection as it's a VPN that has been configured to connect always with the NEOnDemandRule. The function will be more or less this one
func stopProtection(completion: #escaping (Result<Void>) -> Void) {
NSLog("Called stopProtection")
NETunnelProviderManager.loadAllFromPreferences { (managers, error) in
if let error = error {
NSLog("[SUBS] ERROR \(error)")
}
if let managers = managers {
if managers.count > 0 {
let index = managers.firstIndex(where: { $0.localizedDescription == Constants.vpnBundleId })
guard let index = index else {
completion(.error(ProtectionServiceError.noKidsVpnInstalled))
return
}
let myManager = managers[index]
myManager.loadFromPreferences(completionHandler: { (error) in
guard error == nil else {
completion(.error(ProtectionServiceError.errorStoppingTunnel))
return
}
// Deactivate the VPN and save it
myManager.isEnabled = false
myManager.saveToPreferences(completionHandler: { (error) in
guard error == nil else {
completion(.error(ProtectionServiceError.errorStoppingTunnel))
return
}
completion(.success(()))
})
})
} else {
completion(.error(ProtectionServiceError.errorStoppingTunnel))
}
}
}
}
All this code and logic is being performed in the extension with all the limitations it supposes. Using the previous function I'd only get the first NSLog saying Called stopProtection but it doesn't load any manager. Calling this from the main target it'd work. I don't know if I can load and modify the manager from the extension or it's another way to do it.
Okay, I have debugged the network extension by attaching to the process and looking into the device Console and this error pops up,
NETunnelProviderManager objects cannot be instantiated from NEProvider processes
So nope, there's the answer!

What is the best practice to deal with RxSwift retry and error handling

I read some post says that the best practice to deal with RxSwift is to only pass fatal error to the onError and pass Result to the onNext.
It makes sense to me until I realise that I can't deal with retry anymore since it only happen on onError.
How do I deal with this issue?
Another question is, how do I handle global and local retry mixes together?
A example would be, the iOS receipt validation flow.
1, try to fetch receipt locally
2, if failed, ask Apple server for the latest receipt.
3, send the receipt to our backend to validate.
4, if success, then whole flow complete
5, if failed, check the error code if it's retryable, then go back to 1.
and in the new 1, it will force to ask for new receipt from apple server. then when it reaches 5 again, the whole flow will stop since this is the second attempt already. meaning only retry once.
So in this example, if using state machine and without using rx, I will end up using state machine and shares some global state like isSecondAttempt: Bool, shouldForceFetchReceipt: Bool, etc.
How do I design this flow in rx? with these global shared state designed in the flow.
I read some post says that the best practice to deal with RxSwift is to only pass fatal error to the onError and pass Result to the onNext.
I don't agree with that sentiment. It is basically saying that you should only use onError if the programmer made a mistake. You should use errors for un-happy paths or to abort a procedure. They are just like throwing except in an async way.
Here's your algorithm as an Rx chain.
enum ReceiptError: Error {
case noReceipt
case tooManyAttempts
}
struct Response {
// the server response info
}
func getReceiptResonse() -> Observable<Response> {
return fetchReceiptLocally()
.catchError { _ in askAppleForReceipt() }
.flatMapLatest { data in
sendReceiptToServer(data)
}
.retryWhen { error in
error
.scan(0) { attempts, error in
let max = 1
guard attempts < max else { throw ReceiptError.tooManyAttempts }
guard isRetryable(error) else { throw error }
return attempts + 1
}
}
}
Here are the support functions that the above uses:
func fetchReceiptLocally() -> Observable<Data> {
// return the local receipt data or call `onError`
}
func sendReceiptToServer(_ data: Data) -> Observable<Response> {
// send the receipt data or `onError` if the server failed to receive or process it correctly.
}
func isRetryable(_ error: Error) -> Bool {
// is this error the kind that can be retried?
}
func askAppleForReceipt() -> Observable<Data> {
return Observable.just(Bundle.main.appStoreReceiptURL)
.map { (url) -> URL in
guard let url = url else { throw ReceiptError.noReceipt }
return url
}
.observeOn(ConcurrentDispatchQueueScheduler(qos: .userInitiated))
.map { try Data(contentsOf: $0) }
}

Pagination until the end

I got an app which is using PubNub as a service for the chat. After logging in, I'd like to download all history messages. Unfortunately, PubNub has limited the maximum number of messages to 100, so you have to use pagination to download all of them, until no more messages arrive.
The workflow I'd like to achieve is the following:
Load the first 100 messages
Handle them (store within the app)
Load the next 100 messages
And so on..
The Method they provide is the following:
client.historyForChannel(channel, start: nil, end: nil, includeTimeToken: false)
{ (result, status) in
// my code here...
}
The thing is, in the "my code here..."-section, I need to call the function again (with a startDate) to load the next 100 messages. But to call the function again, I need to set a completion block which does exactly the same as the completion block which called the function. This is an infinite loop.. How can I solve this differently? Thank you very much!
PubNub History Pagination Sample Code
All the SDKs have sample code for implementing history pagination. Please refer to the PubNub Swift SDK Storage API Reference History Paging section.
Here is the code inlined:
Paging History Responses:
You can call the method by passing 0 or a valid time token as the argument.
// Pull out all messages newer then message sent at 14395051270438477.
let date = NSNumber(value: (14395051270438477 as CUnsignedLongLong));
self.historyNewerThen(date, onChannel: "history_channel", withCompletion: { (messages) in
print("Messages from history: \(messages)")
})
func historyNewerThen(_ date: NSNumber, onChannel channel: String,
withCompletion closure: #escaping (Array<Any>) -> Void) {
var msgs: Array<Any> = []
self.historyNewerThen(date, onChannel: channel, withProgress: { (messages) in
msgs.append(contentsOf: messages)
if messages.count < 100 { closure(msgs) }
})
}
private func historyNewerThen(_ date: NSNumber, onChannel channel: String,
withProgress closure: #escaping (Array<Any>) -> Void) {
self.client?.historyForChannel(channel, start: date, end: nil, limit: 100,
reverse: false, withCompletion: { (result, status) in
if status == nil {
closure((result?.data.messages)!)
if result?.data.messages.count == 100 {
self.historyNewerThen((result?.data.end)!, onChannel: channel,
withProgress: closure)
}
}
else {
/**
Handle message history download error. Check 'category' property
to find out possible reason because of which request did fail.
Review 'errorData' property (which has PNErrorData data type) of status
object to get additional information about issue.
Request can be resent using: [status retry];
*/
}
})
}
Refer to same section in other SDKs language specific implementations.

How do I implement saving 1000s of records using CloudKit and Swift without exceeding 30 or 40 requests/second

Let me describe the basic flow that I am trying to implement:
User logs in
System retrieves list of user's connections using HTTP request to 3rd party API (could be in the 1000s). I'll call this list userConnections
System retrieves stored connections from my app's database (could be in the 100,000s). I'll call this list connections
System then checks to see if each userConnection exists in the connections list already and if not, saves it to database:
for userConnection in userConnections {
if connections.contains(userConnection) {
//do nothing
} else {
saveRecord(userConnection)
}
}
The problem with this is that when the first users log in, the app will try to make 1000 saveRecord calls in a second which the CloudKit server will not allow.
How can I implement this in a different way using CloudKit and Swift so that I keep it to an acceptable number of requests/second, like ~30 or 40?
For anyone wondering, this is how I ended up doing it. The comment by TroyT was correct that you can batch save your records. This answer includes bonus of queued batches:
let save1 = CKModifyRecordsOperation(recordsToSave: list1, recordIDsToDelete: nil)
let save2 = CKModifyRecordsOperation(recordsToSave: list2, recordIDsToDelete: nil)
save1.database = publicDB
save2.database = publicDB
save2.addDependency(save1)
let queue = NSOperationQueue()
queue.addOperations([save1, save2], waitUntilFinished: false)
save1.modifyRecordsCompletionBlock = { savedRecords, deletedRecordsIDs, error in
if (error != nil){
//handle error
}else{
//data saved
}
}

Server error while resetting the CloudKit badge

For some weeks now my app is unable to reset the CloudKit badge. I am getting a 'Network Failure' error. It did work before and I have not changed any code. I cannot find anything about changed functionality. Is this a CloudKit bug? Should i file a Radar? Or am I doing something wrong?
Here is the code that I use:
public func setBadgeCounter(count:Int) {
let badgeResetOperation = CKModifyBadgeOperation(badgeValue: count)
badgeResetOperation.modifyBadgeCompletionBlock = { (error) -> Void in
func handleError(error: NSError) -> Void {
EVLog("Error: could not reset badge: \n\(error)")
}
self.handleCallback(error, errorHandler: handleError, completionHandler: {
UIApplication.sharedApplication().applicationIconBadgeNumber = count
})
}
CKContainer.defaultContainer().addOperation(badgeResetOperation)
}
internal func handleCallback(error: NSError?, errorHandler: ((error: NSError) -> Void)? = nil, completionHandler: () -> Void) {
if (error != nil) {
EVLog("Error: \(error?.code) = \(error?.description) \n\(error?.userInfo)")
if let handler = errorHandler {
handler(error: error!)
}
} else {
completionHandler()
}
}
The error that I get with this is:
04/15/2015 09:12:28:837 AppMessage)[10181:.] EVCloudKitDao.swift(202) handleCallback(_:errorHandler:completionHandler:):
Error: Optional(4) = Optional("<CKError 0x7fb451c77c10: \"Network Failure\" (4/-1003); \"A server with the specified hostname could not be found.\">")
Optional([NSErrorFailingURLStringKey: https://ckdevice.icloud.com/api/client/badgeUpdate, _kCFStreamErrorCodeKey: 8, NSDebugDescription: NSURLErrorDomain: -1003, NSLocalizedDescription: A server with the specified hostname could not be found., NSErrorFailingURLKey: https://ckdevice.icloud.com/api/client/badgeUpdate, NSUnderlyingError: Error Domain=NSURLErrorDomain Code=-1003 "A server with the specified hostname could not be found." UserInfo=0x7fb45351a890 {NSErrorFailingURLStringKey=https://ckdevice.icloud.com/api/client/badgeUpdate, NSErrorFailingURLKey=https://ckdevice.icloud.com/api/client/badgeUpdate, _kCFStreamErrorDomainKey=12, _kCFStreamErrorCodeKey=8, NSLocalizedDescription=A server with the specified hostname could not be found.}, _kCFStreamErrorDomainKey: 12])
04/15/2015 09:12:28:839 AppMessage)[10181:.] EVCloudKitDao.swift(788) handleError:
Error: could not reset badge:
<CKError 0x7fb451c77c10: "Network Failure" (4/-1003); "A server with the specified hostname could not be found.">
A complete functional app with this problem can be found at https://github.com/evermeer/EVCloudKitDao
It's an Apple Bug. I use your Apple based API, timeout a user when attempting to log in from a foreign country. Using VPN from US solves the problem, suggesting Cloudkit isn't friendly to non-US territories. Using the API no problem before, it seems this is their latest change.
Specifically,
CKContainer.fetchUserRecordIDWithCompletionHandler, takes too long or never return.
Reference from Apple Doc
At startup time, fetching the user record ID may take longer while
CloudKit makes the initial iCloud account request. After the initial
fetch, accessing the user record ID should take less time. If no
iCloud account is associated with the device, or if access to the
user’s iCloud account is restricted, this method returns an error of
type CKErrorNotAuthenticated.
#Edwin Vermeer
Please correct me if you have solved it with the updated of your API or Apple fixes it.

Resources