Error Handling (SDK Layer)#
SDK Layer failures fall into the same three categories as the rest of the stack: retryable, unrecoverable, and OAuth protocol. HaapiManager and OAuthTokenManager surface them with one important addition over the Driver Layer — OAuth protocol errors are returned as ErrorTokenResponse, not thrown.
For the categorization and per-subtype guidance, see Error Handling. For the underlying retryable / unrecoverable mechanics shared with the Driver Layer, see Error Handling (Driver Layer).
Decision Table#
When an SDK call fails, classify the surface and respond accordingly:
| Surface | What it means | What to do |
|---|---|---|
iOS HaapiError with recoverySuggestion == .retryable(let condition) / Android IdsvrHaapiException.Retryable / RN HaapiError with recovery.kind === 'retryable' | Transient — network blip, fresh DPoP nonce required, 5xx with Retry-After | Retry per the condition (Now or WhenAppForeground) |
iOS recoverySuggestion == .newHaapiFlow / RN recovery.kind === 'newHaapiFlow' | The current flow is no longer usable but a fresh one will succeed | Construct a new manager and start over |
iOS recoverySuggestion == .nonRecoverable(let action) / Android IdsvrHaapiException.Unrecoverable / RN recovery.kind === 'unrecoverable' | Misconfiguration, unsupported platform, or access denied | Surface the cause to the user or developer; do not auto-retry |
TokenResponse with ErrorTokenResponse carrying an OAuth error code | Server understood and explicitly rejected (e.g., invalid_grant, invalid_dpop_proof) | Inspect the error code — see “Common OAuth Error Codes” below |
Retryable and Unrecoverable 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):
// condition is .now (retry immediately with exponential backoff)
// or .whenAppForeground (defer until the app is foregrounded)
break
case .newHaapiFlow:
// start a fresh HAAPI flow
break
case .nonRecoverable(let action):
// action is .modifyConfiguration (developer to fix client settings),
// .invalidPlatform (device cannot satisfy the protocol),
// or .introspectCause (inspect the wrapped cause for follow-up)
break
}
}Specific HaapiError cases — attestationFailure, haapiTokenManagerIsClosed, invalidStatusCode, serverError, dpopKeyCreationFailure, dpopProofFailure — can be matched directly when their context matters:
switch haapiError {
case .attestationFailure(let cause):
// attestation rejected — surface to user; consider DCR fallback
break
case .haapiTokenManagerIsClosed:
// construct a new manager and start over
break
case .dpopKeyCreationFailure, .dpopProofFailure:
// rare; rebuild configuration with .p256 and re-authenticate
break
default:
break
}HaapiManager and OAuthTokenManager throw IdsvrHaapiException. The SDK performs a single automatic retry on transport-level retryable conditions before propagating:
try {
val response = haapiManager.start(coroutineContext)
handleHaapiResponse(response)
} catch (e: IdsvrHaapiException.Retryable) {
// e.condition is Now (retry immediately) or WhenAppForeground (defer)
} catch (e: IdsvrHaapiException.Unrecoverable) {
// e.action is ModifyConfiguration, InvalidPlatform, or 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.
Every SDK failure rejects the Promise with a typed HaapiError. Catch it with try/catch and narrow with isHaapiError(e), then branch on the stable error.code:
import { isHaapiError, HaapiErrorCode } from 'identityserver.haapi.reactnative.sdk'
import type { HaapiError } from 'identityserver.haapi.reactnative.sdk'
try {
const response = await accessor.haapiManager.start()
// …
} catch (e) {
if (!isHaapiError(e)) throw e
switch (e.code) {
case HaapiErrorCode.Networking:
case HaapiErrorCode.DPoP:
// e.recovery.kind === 'retryable' — retry per e.recovery.condition.
break
case HaapiErrorCode.Attestation:
// Attestation rejected — surface to user; consider DCR fallback.
break
case HaapiErrorCode.AccessorAlreadyInitialized:
case HaapiErrorCode.AccessorNotInitialized:
// Lifecycle misuse — close any held accessor and re-initialise.
break
case HaapiErrorCode.Configuration:
// e.recovery.kind === 'unrecoverable' — fix client config.
break
}
}HaapiError also exposes category ('bridge' | 'networking' | 'attestation' | 'dpop' | 'haapi' | 'config' | 'unexpected') and recovery: { kind: 'retryable' | 'newHaapiFlow' | 'unrecoverable', condition?, action? } for the higher-level branching pattern. For HAAPI server errors, e.serverError carries the parsed RFC 7807 payload.
Server-side HaapiResponse problems are values, not throws. Server-side authentication problems come back inside HaapiResponse as responseCategory === 'problem' — handle them in the normal flow path, not in a catch.
OAuth Protocol Errors#
OAuth protocol errors (invalid_grant, invalid_dpop_proof, and so on) come back as ErrorTokenResponse from TokenResponse rather than as exceptions. Match on the response variant and inspect the parsed error and errorDescription fields:
switch tokenResponse {
case let .successfulToken(success):
// persist tokens
break
case let .errorToken(errorResponse):
// errorResponse.error and errorResponse.errorDescription
break
case let .error(anError):
// framework or transport error — see Retryable / Unrecoverable above
break
}when (tokenResponse) {
is SuccessfulTokenResponse -> {
// persist tokens
}
is ErrorTokenResponse -> {
tokenResponse.error // "invalid_grant", "invalid_dpop_proof", etc.
tokenResponse.errorDescription
}
}if (tokenResponse.responseType === 'success') {
// persist tokens
} else {
tokenResponse.error // "invalid_grant", "invalid_dpop_proof", etc.
tokenResponse.errorDescription
tokenResponse.errorUri
}OAuth-protocol errors resolve the Promise — they don’t throw. The try/catch block above only fires for transport / attestation / DPoP / bridge failures.
invalid_grant ends that sign-in. Treat the rejected token and the access token issued with it as invalid, drop the cached session, and prompt the user to re-authenticate from scratch — re-issuing the same request will not recover. The user’s other sign-ins are unaffected.
Common OAuth Error Codes#
The codes below are the ones most apps will encounter. The list is not exhaustive — any RFC 6749 / RFC 9449 error code may surface — but covers the operationally meaningful cases:
error | Typical cause | Recommended response |
|---|---|---|
invalid_grant | Authorization code or refresh token has expired, been revoked, or was bound to a key that no longer exists | Discard those tokens and prompt re-authentication; other sign-ins are unaffected |
invalid_dpop_proof | Client binding configuration does not match the server’s issue-token-bound-authorization-code setting, or the DPoP proof was missing/malformed on token exchange | Verify client and server binding settings match — see Token Binding |
invalid_request | Token request is malformed (missing parameter, duplicated parameter, unsupported parameter) | Usually a client bug; inspect the outgoing request and reconcile with the OAuth spec |
invalid_client | Client authentication failed (wrong secret, expired certificate, JWT signature mismatch) | Verify the configured client authentication method matches the server’s main HAAPI client — see Client Authentication |
unauthorized_client | The client is authenticated but not permitted to use this grant type or scope | Server-side configuration: check the client’s allowed grant types and scopes on CIS |
use_dpop_nonce | Server requires a fresh DPoP nonce before accepting the request | Surfaces as a retryable error at the Driver Layer; the SDK refreshes the nonce and retries automatically |
SDK Refusal Codes#
These are not OAuth errors. The server never sends them: the SDK raises them itself, before or instead of a request, and reports them through the same error field.
error | What happened | Recommended response |
|---|---|---|
non_haapi_link | followLink was asked to follow a link whose target is not the configured identity server — a different host, or a scheme that is not the server’s. No request was transmitted, so no token or DPoP proof left the device. | Almost always an identity-server or reverse-proxy misconfiguration emitting off-host links. Check the server’s configured base URL and any proxy rewriting. Do not retry: the outcome is deterministic. |
Because a HAAPI request carries the access token and a DPoP proof, its destination is a security boundary — this refusal is what keeps those credentials from being sent to an unintended host.
The returned message deliberately names no host, since it may reach an end user; the rejected and configured hosts are written to the log instead. On iOS, compare against the typed constant rather than the string:
if case .error(let error) = result,
(error as? IdsvrError)?.error == HaapiErrorCode.nonHaapiLink.rawValue
{
// The link target was not the configured identity server; nothing was sent.
}
followLink is guarded; submitForm is not. Extending the check to form submission is tracked separately, because a false positive there would break authentication outright.
How to implement this: Error Handling (concept) · How to Handle Errors · Error Handling (Driver Layer)