How should a sync layer react to CKError codes: partial failures, retry-after, conflicts and expired tokens?

17.11.2 · L5 · Interview

Predict first

What does each error ask for?

import Foundation
import CloudKit

func advice(_ e: CKError) -> String {
    switch e.code {
    case .requestRateLimited, .zoneBusy, .serviceUnavailable:
        return "retry after \(e.retryAfterSeconds ?? 0)s"
    case .networkUnavailable, .networkFailure: return "wait for the network, then retry"
    case .serverRecordChanged: return "merge with the server record, save again"
    case .changeTokenExpired: return "drop the token, refetch everything"
    case .zoneNotFound: return "create the zone, then retry"
    case .quotaExceeded: return "tell the user; do not retry"
    case .notAuthenticated: return "ask the user to sign in to iCloud"
    case .unknownItem: return "the record is gone; delete locally"
    case .limitExceeded: return "split the batch and retry"
    case .partialFailure: return "inspect each item"
    default: return "log and surface"
    }
}
let id1 = CKRecord.ID(recordName: "a"), id2 = CKRecord.ID(recordName: "b")
let partial = CKError(.partialFailure, userInfo: [CKPartialErrorsByItemIDKey: [
    id1: CKError(.unknownItem),
    id2: CKError(.serverRecordChanged)]])
print("top level:", partial.code.rawValue, advice(partial))
for (id, e) in partial.partialErrorsByItemID!.sorted(by: { ($0.key as! CKRecord.ID).recordName < ($1.key as! CKRecord.ID).recordName }) {
    let err = e as! CKError
    print((id as! CKRecord.ID).recordName, err.code.rawValue, advice(err))
}
print(advice(CKError(.requestRateLimited, userInfo: [CKErrorRetryAfterKey: 7.5])))
for c in [CKError.Code.changeTokenExpired, .zoneNotFound, .quotaExceeded, .limitExceeded, .notAuthenticated] { print(c.rawValue, advice(CKError(c))) }
  • Partial failure inspects items: a is deleted locally, b merged; rate limit waits 7.5 s; expired token refetches
  • Every error is retried at once
  • quotaExceeded is retried

Mental model. Errors are signposts: some say wait, some say turn around, some say you have no ticket.

Operations report failure as CKError; branch on code. A batch usually fails as .partialFailure (code 2), whose partialErrorsByItemID holds one CKError per item, so you must look inside: in the run “a” is .unknownItem (the record is gone: remove it locally) and “b” is .serverRecordChanged (code 14: fetch the server record, merge, save again with its change tag). Transient errors (.requestRateLimited, .zoneBusy, .serviceUnavailable) carry retryAfterSeconds (7.5 here) and you should wait at least that long; network errors wait for connectivity; .changeTokenExpired (21) means throw away the token and refetch; .zoneNotFound (26) means create the zone; .limitExceeded (27) means split the batch; .quotaExceeded (25) and .notAuthenticated (9) are for the user, not for a retry loop.

Write the mapping as one function (as the run does) and cover it with tests that construct CKError values, which works without any account. Make operations idempotent so a retry after a lost response is safe, and never retry in a tight loop. The code values and retry hints are from constructed errors; real server behaviour and rate limits were not exercised.

Each CKError code has its own recovery.

Common traps

  • Treating partialFailure as one error. Each item has its own.
  • Ignoring retry-after. You will be rate limited harder.

They might follow up with

  • How do I resolve a conflict? Merge the field-level changes onto the server record and save it.

Related questions

  • 17.4.1 How does CloudKit work: containers, databases, zones, records, subscriptions, CKShare and sync?
  • 17.11.1 What is in a CKRecord, why archive its system fields, and what are the zone rules for references and parents?

← What is in a CKRecord, why archive its system fields, and what are…