Error Handling (Driver Layer)#

Driver Layer failures fall into the same three categories as the rest of the stack: retryable, unrecoverable, and OAuth protocol. For the categorization and per-subtype guidance, see Error Handling.

The two platforms surface errors through different idioms — iOS returns Error values via completion handlers; Android throws IdsvrHaapiException. Casting or matching gives you the typed metadata.

Handling Errors#

Completion handlers surface Error values. Cast to HaapiError for the enum, then consult recoverySuggestion for retry guidance:

private func handleError(_ error: Error) {
    guard let haapiError = error as? HaapiError else { return }
    switch haapiError.recoverySuggestion {
    case .retryable(let condition):
        // schedule a retry based on the condition
        break
    case .newHaapiFlow:
        // start a fresh HAAPI flow
        break
    case .nonRecoverable(let action):
        // surface the cause to the user; no retry possible
        break
    }
}

Specific HaapiError cases — attestationFailure, haapiTokenManagerIsClosed, invalidStatusCode, serverError, and others — can be matched directly when their context matters:

switch haapiError {
case .attestationFailure(let cause):
    // attestation rejected — surface to user, do not auto-retry
    break
case .haapiTokenManagerIsClosed:
    // construct a new manager and start over
    break
case .invalidStatusCode(let statusCode):
    // server returned an unexpected status
    break
default:
    break
}

The driver throws IdsvrHaapiException, split into two main categories:

try {
    val result = throwableOperation()
} catch (e: IdsvrHaapiException.Retryable) {
    // transient — schedule retry per e.condition (Now or WhenAppForeground)
} catch (e: IdsvrHaapiException.Unrecoverable) {
    // permanent — handle per e.action (ModifyConfiguration, InvalidPlatform, IntrospectCause)
}

Retryable subtypes include HttpClientRetryableException.SocketStreamInterruptionException, HostConnectionException, HttpRetryException (408, 429, 5xx with Retry-After), and HaapiRetryableException.UseDpopNonceException. Unrecoverable subtypes include AccessDeniedException, UnsupportedDeviceException, and UnsupportedConfigurationException.

When HttpClient.Response.Failure has a response code of 0, the failure is internal (parsing, attestation, key generation) rather than a server response.

OAuth Protocol Errors#

OAuth protocol errors are returned as part of the response payload from the token endpoint, not as exceptions at the Driver Layer. They surface at the SDK Layer through OAuthTokenManager as ErrorTokenResponse. See OAuthTokenManager for the OAuth-specific handling.

Storage Errors#

The driver persists credentials through the Storage protocol. The built-in keychain-backed store is built with StorageBuilder.Keychain() — optionally with a shared access group and a data-protection level:

let storage = StorageBuilder.Keychain()
    .accessGroup("group.example.shared")            // optional — share with same-team targets
    .accessibility(.whenUnlockedThisDeviceOnly)     // optional — default: .afterFirstUnlockThisDeviceOnly
    .build()

Higher layers accept this store via HaapiConfiguration(storage:) and HaapiUIKitConfigurationBuilder.setStorage(_:); see Configuration (UI Layer).

When the keychain is unavailable (device locked, or an unusable access group), the thrown StorageError.readError / .writeError / .deleteError carries a typed, inspectable cause of StorageError.unavailable(operation:category:code:) — non-sensitive diagnostic context only, never the key or value. During a DPoP operation this still surfaces as HaapiError with recoverySuggestion == .retryable(.whenAppInForeground), so the retryable-error handling shown above already covers it.

A misconfigured access group is flagged early by a non-throwing probe that logs a warning; the store does not silently fall back to app-private storage, so this typed error remains the single authoritative signal on the first real keychain operation.

The driver persists DPoP state through the Storage interface. The built-in stores — StorageBuilder.encrypted(context) (the default) and StorageBuilder.shared(context) — encrypt values at rest under a non-exportable Android Keystore key, so a value can become unreadable if the entry is corrupted or the Keystore key is lost.

Read operations handle that differently:

  • Bulk reads (Storage.getAll) omit entries whose values no longer decrypt instead of failing the whole read. Each omission is logged under the HAAPI_DRIVER_STORAGE tag — the record carries the entry’s storage key, never the value.
  • Single-key reads (Storage.get) propagate the failure to the caller, so an unreadable entry is distinguishable from an absent one.

Was this helpful?