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
}

An attestationFailure can also mean that the device reached the App Attest key limit. Its error is then HaapiErrorCode.attestationLimited (attestation_limited) and its cause a HaapiAttestationLimitRefusal, and the SDK does not fall back to Dynamic Client Registration for it. Retry no earlier than the cause’s nextAttemptAfter: the recoverySuggestion, .retryable(condition: .whenAppInForeground), carries no time. See App Attest Key Limit (iOS).

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 '@curity/identityserver-haapi-react-native-sdk'
import type { HaapiError } from '@curity/identityserver-haapi-react-native-sdk'

try {
    const response = await accessor.haapiManager.start()
    // …
} catch (e) {
    if (!isHaapiError(e)) throw e

    switch (e.code) {
        case HaapiErrorCode.Communication:
        case HaapiErrorCode.DpopNonceRequired:
        case HaapiErrorCode.StorageUnavailable:
            // e.recovery?.kind === 'retryable' — retry per e.recovery.condition.
            break
        case HaapiErrorCode.AttestationFailure:
        case HaapiErrorCode.AttestationRefusedByServer:
            // 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.InvalidConfiguration:
            // e.recovery?.kind === 'unrecoverable' — fix client config.
            break
        case HaapiErrorCode.InvalidArgument:
            // A call's input can't be used — fix the call; e.errorDescription names the parameter.
            break
    }
}

HaapiError also exposes category ('bridge' | 'networking' | 'attestation' | 'dpop' | 'haapi' | 'configuration' | 'unexpected') and an optional 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.

On iOS, AttestationFailure can also mean that the device reached the App Attest key limit. That failure is retryable later — check e.recovery — and the SDK does not fall back to Dynamic Client Registration for it. See App Attest Key Limit (iOS).

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.
foreign_haapi_targetEvery HAAPI request is checked before it is sent; this code means a session start, form submission or auto-redirection was aimed at an address that is neither the configured base URL nor the authorization endpoint. Nothing was transmitted, including the form body, and no token or proof was obtained for that target. A link follow is held to the base URL alone and reports non_haapi_link instead, so a link to an authorization endpoint on a separate host is refused even though a form may target it.Same causes as non_haapi_link, but it can fire on a credential-carrying submission. Check the base URL, the authorization endpoint, and any proxy rewriting. Do not retry.
haapi_redirect_refusedThe server answered a request with an HTTP redirect — a 301, 302, 303, 307 or 308 with a Location — which the SDK never follows. Nothing was sent to the redirect target. HAAPI expresses redirection inside the representation, so a transport-level redirect would move the request to an unvalidated address. On both platforms every request the SDK sends is covered, OAuth token, refresh and revocation requests included. The cause is a HaapiRedirectRefusal carrying the status code.A reverse-proxy or server misconfiguration. Do not redirect the SDK’s requests; rewrite paths and terminate TLS at the proxy instead — see Upgrade — Redirects Are Refused. Do not retry: the outcome is deterministic.
insecure_haapi_configurationThe session was built with a base URL, authorization endpoint, token endpoint, configured revocation endpoint or client registration endpoint that does not use https. The refusal names which one. It is thrown when the HaapiManager or accessor is created — a HaapiError on iOS, an IdsvrHaapiException.Unrecoverable on Android — with a HaapiInsecureConfigurationRefusal as its cause.Fix the address. For a local or test deployment, set allowInsecureHttpEndpoints to true deliberately — on iOS through the HaapiConfiguration initializer that takes it, or setAllowInsecureHttpEndpoints(value:) on HaapiUIKitConfigurationBuilder; on Android through the HaapiConfiguration property, or setAllowInsecureHttpEndpoints(true) on WidgetConfiguration.Builder. It admits http and no other scheme — see HAAPI Request Target Hardening.
insecure_transportA client authentication method that requires TLS (Mtls, MtlsKeyHash on Android; mutual TLS on iOS) was configured, but the endpoint the SDK connected to is not https://. Nothing was sent. Raised by the Android and iOS SDKs.Change the endpoint to https://. See Upgrade — Server Key Pinning.
attestation_limitediOS only. A new App Attest key was refused because the device reached the app-wide App Attest key limit — 6 new keys at once, then one more per hour — typically because a server kept rejecting or dropping the attestation state. No key was generated and nothing was sent to Apple. It is thrown when the accessor is created, or returned in a result when a request needs a new key; the cause is a HaapiAttestationLimitRefusal carrying nextAttemptAfter, and the SDK does not fall back to DCR for it.Do not retry before nextAttemptAfter: an earlier attempt fails the same way, and the recoverySuggestion carries no time. When nextAttemptAfter is nil, retry when the app is next in the foreground. Look for repeated attestation rejections on the server — see App Attest Key Limit (iOS).

Because a HAAPI request carries the access token and a DPoP proof, its destination is a security boundary — the non_haapi_link 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. 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.
}

A refused redirect also carries a typed cause, HaapiRedirectRefusal, with the redirect’s status code:

if case .error(let error) = result,
   let idsvrError = error as? IdsvrError,
   idsvrError.error == HaapiErrorCode.haapiRedirectRefused.rawValue,
   let refusal = idsvrError.cause as? HaapiRedirectRefusal
{
    // refusal.statusCode: the 3xx status the server answered with
}

An insecure configuration is thrown when the session is set up rather than returned in a result, so catch it where you create the accessor or HaapiManager:

do {
    let haapiAccessor = try await HaapiAccessorBuilder(haapiConfiguration: haapiConfiguration).buildForHaapi()
} catch let error as IdsvrError where error.error == HaapiErrorCode.insecureHaapiConfiguration.rawValue {
    // A configured identity-server address does not use https; nothing was sent.
    let refusal = error.cause as? HaapiInsecureConfigurationRefusal
    // refusal?.address: .baseURL, .authorizationEndpoint, .tokenEndpoint, .revocationEndpoint or .clientRegistrationEndpoint
}

Its recovery suggestion is .unrecoverable(action: .modifyConfiguration(reason:)): fix the address, do not retry. The deprecated completion-handler HaapiAccessorBuilder.build delivers the same error.

HaapiManager throws a refusal as an IdsvrHaapiException.Unrecoverable. A refused redirect carries a typed cause, HaapiRedirectRefusal, with the redirect’s status code:

try {
    val response = haapiManager.followLink(link)
} catch (e: IdsvrHaapiException) {
    when (e.error) {
        DriverErrorCodes.NON_HAAPI_LINK -> {
            // The link target was not the configured identity server; nothing was sent.
        }
        DriverErrorCodes.HAAPI_REDIRECT_REFUSED -> {
            val refusal = e.cause as? HaapiRedirectRefusal
            // refusal?.statusCode: the 3xx status the server answered with
        }
    }
}

An insecure configuration is thrown when the session is set up, so catch it where you create the accessor or HaapiManager:

try {
    val haapiAccessor = HaapiAccessorFactory(haapiConfiguration).createForHaapi()
} catch (e: IdsvrHaapiException) {
    if (e.error == DriverErrorCodes.INSECURE_HAAPI_CONFIGURATION) {
        // A configured identity-server address does not use https; nothing was sent.
        val refusal = e.cause as? HaapiInsecureConfigurationRefusal
        // refusal?.address: BASE_URL, AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT, REVOCATION_ENDPOINT or CLIENT_REGISTRATION_ENDPOINT
    }
}

Its action is UnrecoverableAction.ModifyConfiguration: fix the address, do not retry. createForOAuth() and the deprecated create() throw the same error.

Both platforms check every HAAPI request; where the check runs differs. On Android every HAAPI request is checked at one point in the HTTP client, so form submission and the auto-submitted redirection step are covered as well — see HAAPI Request Target Hardening. On iOS each request — session start, form submission, the auto-submitted redirection step and link follow — is checked in HaapiManager. On both, the check runs before the SDK fetches an access token or mints a DPoP proof for the request, so a refused target costs neither.

Was this helpful?