How to Handle Errors#

You want a single error-handling layer that classifies every failure the SDK throws into “retry”, “ask the user to start over”, “show a permanent failure”, or “this is an OAuth protocol error from the server”. This page wires the three error categories from Error Handling into a working dispatch.

For the categorization concept, see Error Handling. For per-layer surface details (decision table, common OAuth codes), see Error Handling (SDK Layer) and Error Handling (Driver Layer).

Prerequisites#

  • A working HAAPI integration (UI Layer or SDK Layer).
  • An understanding of the three categories: retryable, unrecoverable, OAuth protocol.

Step-by-Step#

1. Triage at the error site#

Every error reaches your code as one of four surfaces. Match the surface first, then dispatch:

SurfaceWhat it meansWhat to do
iOS HaapiError with .retryable(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 .newHaapiFlow / RN recovery.kind === 'newHaapiFlow'Current flow no longer usable; a fresh one will succeedConstruct a new manager and start over
iOS .nonRecoverable(action) / Android IdsvrHaapiException.Unrecoverable / RN recovery.kind === 'unrecoverable'Misconfiguration, unsupported platform, access deniedSurface to user/developer; do not auto-retry
TokenResponse with ErrorTokenResponse carrying an OAuth error codeServer understood and explicitly rejected (invalid_grant, invalid_dpop_proof, etc.)Inspect the error code; see “Common OAuth Error Codes” below

2. Implement the dispatch#

SDK Layer#

private func handleHaapiError(_ error: Error) {
    guard let haapiError = error as? HaapiError else {
        showFatal("Unexpected error: \(error)")
        return
    }
    switch haapiError.recoverySuggestion {
    case .retryable(let condition):
        scheduleRetry(condition)            // condition: .now or .whenAppForeground
    case .newHaapiFlow:
        restartFlow()
    case .nonRecoverable(let action):
        handleNonRecoverable(action)        // .modifyConfiguration / .invalidPlatform / .introspectCause
    }
}

private func handleTokenResponse(_ tokenResponse: TokenResponse) {
    switch tokenResponse {
    case .successfulToken(let success): persist(success)
    case .errorToken(let err):          handleOAuthError(err.error, err.errorDescription)
    case .error(let err):               handleHaapiError(err)
    }
}

UI Layer#

At the UI Layer, errors arrive through HaapiFlowResult.didReceiveError(_:) — the same dispatch applies. Cast the Error to HaapiError and route through handleHaapiError.

SDK Layer#

try {
    val response = haapiManager.start(coroutineContext)
    handleHaapiResponse(response)
} catch (e: IdsvrHaapiException.Retryable) {
    scheduleRetry(e.condition)            // condition: Now or WhenAppForeground
} catch (e: IdsvrHaapiException.Unrecoverable) {
    handleNonRecoverable(e.action)        // ModifyConfiguration / InvalidPlatform / IntrospectCause
}

fun handleTokenResponse(tokenResponse: TokenResponse) {
    when (tokenResponse) {
        is SuccessfulTokenResponse -> persist(tokenResponse)
        is ErrorTokenResponse -> handleOAuthError(tokenResponse.error, tokenResponse.errorDescription)
    }
}

UI Layer#

At the UI Layer, errors arrive in the OauthModel.Error branch of the HaapiFlowActivity result, or as a Throwable in the RESULT_CANCELED data. Wrap that handling with the same dispatch shape — route Throwables through the SDK-Layer try/catch semantics manually.

React Native has only an SDK Layer surface. SDK failures (network, attestation, DPoP, bridge invariants) reject the Promise with a typed HaapiError; OAuth-protocol errors come back inside the resolved TokenResponse. Catch both surfaces in the same async block:

import { isHaapiError, HaapiErrorCode } from 'identityserver.haapi.reactnative.sdk'
import type { HaapiResponse, TokenResponse } from 'identityserver.haapi.reactnative.sdk'

try {
    const response: HaapiResponse = await accessor.haapiManager.start()
    handleHaapiResponse(response)
} catch (e) {
    if (!isHaapiError(e)) throw e
    switch (e.recovery.kind) {
        case 'retryable':
            scheduleRetry(e.recovery.condition)   // 'now' or 'whenAppForeground'
            break
        case 'newHaapiFlow':
            restartFlow()
            break
        case 'unrecoverable':
            handleNonRecoverable(e.recovery.action)
            break
    }
}

function handleTokenResponse(tokenResponse: TokenResponse) {
    if (tokenResponse.responseType === 'success') {
        persist(tokenResponse)
    } else {
        handleOAuthError(tokenResponse.error, tokenResponse.errorDescription)
    }
}

For finer-grained branching, switch on e.code (the stable HaapiErrorCode enum) — see Error Handling (SDK Layer).

3. Handle OAuth protocol errors by code#

The common codes and recommended responses:

error codeTypical causeResponse
invalid_grantAuth code or refresh token expired, revoked, or unboundDrop those cached tokens; prompt re-auth. Other sign-ins are unaffected.
invalid_dpop_proofClient / server binding settings mismatch, or DPoP proof missing on token exchangeReconcile via Token Binding’s server/client matrix
invalid_requestMalformed token requestInspect the outgoing parameters; usually a client bug
invalid_clientClient authentication failed (bad secret, expired cert, JWT signature mismatch)Verify Client Authentication matches the server’s main HAAPI client
unauthorized_clientClient authenticated but not permitted to use this grant typeServer-side: check grant types and scopes on CIS
use_dpop_nonceServer requires a fresh DPoP nonceThe SDK refreshes and retries automatically; surfaces only if the retry also fails

4. Handle SDK refusals#

One code in the error field does not come from the server at all — the SDK raises it itself:

error codeTypical causeResponse
non_haapi_linkfollowLink was given a link whose target host or scheme is not the configured identity server. Nothing was transmitted.Do not retry — the outcome is deterministic. Treat it as a server or reverse-proxy misconfiguration emitting off-host links, and check the configured base URL.

Classifying links with link.kind before following them (see iOS Representations) avoids most of these, since download, text/html and custom-scheme links are then opened in the browser rather than followed. See Error Handling for the full description.

Pitfalls#

  • invalid_grant after a successful refresh. Treat those tokens as invalid — re-issuing the same request will not recover. Prompt re-auth from scratch. The user’s other sign-ins are unaffected.
  • Double-handling with the token-endpoint listener. If you’ve registered a TokenEndpointResponseListener (see Token Endpoint Response Listener), errors arrive in both the listener AND the calling method. Decide on one as the user-facing handler and use the other for raw audit only.
  • Treating transport errors as OAuth errors. Transport-level failures (DNS, TLS, socket interruptions) surface as the Error case (iOS) or IdsvrHaapiException (Android), not as ErrorTokenResponse. Confusing these wastes time chasing the wrong root cause.
  • Auto-retry without backoff. On .retryable(.now) / Retryable(Now), retry with exponential backoff — not in a tight loop. The server’s transient state may need seconds, not milliseconds.

Was this helpful?