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.
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
| Case | Notes |
|---|---|
auth(AuthError) | Wraps the auth failures below |
deviceUnsupported | No 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
| Case | Notes |
|---|---|
appAttestUnavailable | Simulator or unsupported device — do not retry |
noOrganizationSelected | prepareDeviceAuth() ran before an organisation was picked — call it again after selection |
keychainFailure(status: OSStatus) | Typically a locked device — retry once unlocked |
attestationInvalid | Often a rejected publishable key or an environment mismatch |
assertionInvalid | The per-request assertion was rejected |
credentialsUnavailable | No 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.
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
failedcase — 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.