Skip to main content

Errors

Everything that can fail is declared throws(NoughtyToursError) — one public, Equatable, switch-able type, thrown typed, so your catch binds it with no cast.

do throws(NoughtyToursError) {
let vc = try await tours.properties.makeFloorViewController(floor: floor, onEvent: handle)
navigationController.pushViewController(vc, animated: true)
} catch {
switch error {
case .notFound: // the entity was deleted under you — your list is stale
navigationController.popViewController(animated: true)
case _ where error.isRetryable:
retryLater()
default:
show(error.localizedDescription, error.recoverySuggestion)
}
}

Every case carries code, isRetryable and a recoverySuggestion, and AuthError mirrors all three.

Three things come out of an error, and they are not interchangeable

Branch on the case, record the code, show the message. The reason strings are diagnostics whose wording changes between versions — never branch on them. New cases arrive in minor versions, so keep a default in exhaustive switches.

NoughtyToursError

CaseNotes
auth(AuthError)Wraps the auth failures below
deviceUnsupportedNo LiDAR — check NoughtyTours.isDeviceSupported first
notFound(NoughtyToursEntity, id: String)The entity was deleted under you
storageUnavailable(reason: String)Terminal — nothing works without local storage
storageFailure(reason: String)A read or write against local storage failed
syncFailed(reason: String)A sync run failed
uploadFailed(reason: String)An upload failed
invalidName(reason: String)Rejected property or floor name
invalidMetadata(reason: String)Rejected metadata payload

NoughtyToursEntity is .property, .floor, .room or .panoramaScan.

Codes are API, messages are not

code is a stable, machine-readable identifier: lowercase, .-separated, never reused for a different meaning. It is what belongs in a log line, a support ticket, or a payload to your own backend — every place where description would rot the moment someone rewords it.

} catch {
logger.error("capture failed: \(error.code, privacy: .public)\(error)")
reporting.record(failure: error.code) // "upload_failed", "auth.keychain_failure"
}

A new case brings a new code, so a code you have already seen keeps its meaning across versions — treat one you do not recognise as unhandled rather than guessing at its shape. auth(_:) nests the wrapped code behind an auth. prefix, which keeps the auth failures greppable as one family: auth.keychain_failure is keychainFailure(status:).

AuthError

CaseNotes
appAttestUnavailableSimulator or unsupported device — do not retry
noOrganizationSelectedprepareDeviceAuth() ran before an organisation was picked — call it again after selection
keychainFailure(status: OSStatus)Typically a locked device — retry once unlocked
attestationInvalidOften a rejected publishable key or an environment mismatch
assertionInvalidThe per-request assertion was rejected
credentialsUnavailableNo usable local credentials
serverError(statusCode: Int)Backend rejected the exchange
transportFailure(String)Network-level failure
malformedResponse(String)Unparseable response

Everything not called out above follows isRetryable.

Before chasing a bug

attestationInvalid and serverError(statusCode:) are usually a rejected key or an environment mismatch, not a defect. Check App Attest first.

Where errors reach you

  • Thrown from a factory, before you have a screen — nothing is on screen yet, so you decide what to show.
  • Reported through a screen's failed case — a write the screen performed failed while the user was looking at it. The screen has already dismissed anything it was presenting.

The SDK never puts up an alert. Failures are reported; you show them the way your app shows failures.