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:

SurfaceWhat it meansWhat 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-AfterRetry 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 succeedConstruct a new manager and start over
iOS recoverySuggestion == .nonRecoverable(let action) / Android IdsvrHaapiException.Unrecoverable / RN recovery.kind === 'unrecoverable'Misconfiguration, unsupported platform, or access deniedSurface the cause to the user or developer; do not auto-retry
TokenResponse with ErrorTokenResponse carrying an OAuth error codeServer 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:

errorTypical causeRecommended response
invalid_grantAuthorization code or refresh token has expired, been revoked, or was bound to a key that no longer existsDiscard those tokens and prompt re-authentication; other sign-ins are unaffected
invalid_dpop_proofClient binding configuration does not match the server’s issue-token-bound-authorization-code setting, or the DPoP proof was missing/malformed on token exchangeVerify client and server binding settings match — see Token Binding
invalid_requestToken request is malformed (missing parameter, duplicated parameter, unsupported parameter)Usually a client bug; inspect the outgoing request and reconcile with the OAuth spec
invalid_clientClient 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_clientThe client is authenticated but not permitted to use this grant type or scopeServer-side configuration: check the client’s allowed grant types and scopes on CIS
use_dpop_nonceServer requires a fresh DPoP nonce before accepting the requestSurfaces 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.

errorWhat happenedRecommended response
non_haapi_linkfollowLink 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.

Was this helpful?