Skip to main content

Error handling

Every failure here is a typed rejection you can branch on; null means exactly one thing.

The contract

const value = await icloudKV.getItem('k')
// null -> the key does not exist. Nothing else returns null.

Everything else rejects:

try {
await store.setItem('k', 'v')
} catch (e) {
e.code // ErrorCode
e.retryAfterMs // on ERR_RATE_LIMITED
e.limitBytes // on ERR_PAYLOAD_TOO_LARGE
e.actualBytes // on ERR_PAYLOAD_TOO_LARGE
e.serverValue // on ERR_CONFLICT
e.serverErrorCode // the backend's own code, when there was one
e.provider // which provider raised it
}

Why it matters

A catch { return null } conflates every failure into one useless signal. Worse, a failed write can look like success: real libraries misreport it, so a quota-exceeded write resolves happily while the data vanishes.

Codes

These codes map onto CloudKit's own (framework, Web Services) and Drive's HTTP statuses; the original is kept on serverErrorCode.

CodeMeaningTypical response
ERR_NOT_SIGNED_INNo account signed inPrompt sign-in
ERR_ACCOUNT_RESTRICTEDParental controls or MDMExplain; do not retry
ERR_ACCOUNT_UNAVAILABLEtemporarilyUnavailableRetry silently later
ERR_ACCOUNT_UNDETERMINEDStatus not yet knownDo nothing yet
ERR_AUTH_EXPIREDCredential expiredRe-auth
ERR_NETWORK_UNAVAILABLEOffline or unreachableQueue and retry
ERR_QUOTA_EXCEEDEDStorage fullTell the user
ERR_RATE_LIMITEDBacking offRetry after retryAfterMs
ERR_PAYLOAD_TOO_LARGEAbove the store's limitUse a bigger provider
ERR_INVALID_KEYKey cannot round-trip through the configured providersFix the key, or sanitizeKey it
ERR_TIMEOUTRan longer than the configured timeoutRetry; it may still be in flight
ERR_CONFLICTA concurrent write wonMerge using serverValue
ERR_CONTAINER_MISCONFIGUREDEntitlement, container or token problemFix the build
ERR_UNSUPPORTED_PLATFORMProvider unavailable hereBranch on isAvailable()
ERR_CANCELLEDCancelled by the callerNothing - they asked for it
ERR_UNKNOWNUnclassified; cause holds the originalReport it

Three deserve a note: ERR_INVALID_KEY fires before the request, so a bad record name doesn't instead surface as a confusing BAD_REQUEST (see keys); ERR_TIMEOUT is only an abandoned wait, not a failure; and ERR_CANCELLED is what a cancelled transfer rejects with - cloudKitAssets.cancel, cloudKitBackup.cancel, or an AbortLike signal to googleDriveFiles.

Classifying without a switch

import { isCancelled, isRetryable, requiresUserAction } from 'react-native-cloud-sync'

try {
await store.setItem('k', 'v')
} catch (e) {
if (isCancelled(e)) return // they asked; say nothing
if (requiresUserAction(e)) promptUser(e.code) // signed out, out of storage
else if (isRetryable(e)) scheduleRetry(e.retryAfterMs)
else report(e)
}

isCancelled comes first: cancelling isn't a fault, so don't toast it. The facade's outbox already queues retryable failures, so most call sites need only requiresUserAction.

Recognising an error

import { isCloudSyncError } from 'react-native-cloud-sync'

if (isCloudSyncError(e)) console.warn(e.code)

Use the guard, not instanceof: a bridge rejection sometimes arrives as a plain object, so it checks shape.

Distinguishing "absent" from "broken"

The one case worth being deliberate about:

let value: string | null = null
try {
value = await store.getItem('playlist')
} catch (e) {
// Reached the cloud and something went wrong. Do NOT treat this as
// "no backup exists" - that is how apps overwrite good remote data
// with an empty local state.
return showSyncError(e)
}

// Genuinely nothing stored yet, so seeding is safe.
if (value == null)
seedInitialState()

That works because the facade raises ERR_NOT_SIGNED_IN, never null, when no provider is reachable - and the reverse holds too:

// One provider down, another up and holding the value -> the value.
// Every provider down -> ERR_NOT_SIGNED_IN, never null.