From ae2a126ac13a967556a9763969b809aa1e3e6cc1 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 12 Jun 2026 16:08:11 +0300 Subject: [PATCH 1/4] Normalize SDK error boundaries --- .../com/omsclient/kotlin_sdk/OmsSdkError.kt | 277 ++++++++++++++---- .../kotlin_sdk/indexer/IndexerClient.kt | 142 ++++++++- .../kotlin_sdk/session/OMSClientSession.kt | 2 +- .../kotlin_sdk/wallet/WalletClient.kt | 81 +++-- .../kotlin_sdk/network/ServiceClientsTest.kt | 13 +- .../kotlin_sdk/wallet/WalletEmailAuthTest.kt | 54 ++++ .../wallet/WalletOidcRedirectAuthTest.kt | 3 +- 7 files changed, 482 insertions(+), 90 deletions(-) diff --git a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/OmsSdkError.kt b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/OmsSdkError.kt index 0d4c037..3fe9f47 100644 --- a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/OmsSdkError.kt +++ b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/OmsSdkError.kt @@ -3,6 +3,7 @@ package com.omsclient.kotlin_sdk import com.omsclient.kotlin_sdk.internal.generated.waas.ErrorKind import com.omsclient.kotlin_sdk.internal.generated.waas.WebRpcError import com.omsclient.kotlin_sdk.internal.generated.waas.WebRpcTransportException +import kotlinx.coroutines.CancellationException /** * Stable SDK-level error categories for app-facing error handling. @@ -17,6 +18,7 @@ enum class OmsSdkErrorCode { WalletSelectionStale, WalletSelectionUnavailable, WalletSelectionInFlight, + TransactionExecutionUnconfirmed, TransactionStatusLookupFailed, ValidationError, } @@ -27,12 +29,15 @@ enum class OmsSdkErrorCode { enum class OmsSdkOperation( val id: String, ) { - PendingWalletSelection("pendingWalletSelection"), - PendingWalletSelectionCreateAndSelectWallet("pendingWalletSelection.createAndSelectWallet"), - PendingWalletSelectionSelectWallet("pendingWalletSelection.selectWallet"), + PendingWalletSelection("wallet.pendingWalletSelection"), + PendingWalletSelectionCreateAndSelectWallet("wallet.pendingWalletSelection.createAndSelectWallet"), + PendingWalletSelectionSelectWallet("wallet.pendingWalletSelection.selectWallet"), + IndexerGetNativeTokenBalance("indexer.getNativeTokenBalance"), + IndexerGetTokenBalances("indexer.getTokenBalances"), WalletCallContract("wallet.callContract"), WalletCompleteEmailAuth("wallet.completeEmailAuth"), WalletCreateWallet("wallet.createWallet"), + WalletExecute("wallet.execute"), WalletGetIdToken("wallet.getIdToken"), WalletHandleOidcRedirectCallback("wallet.handleOidcRedirectCallback"), WalletGetTransactionStatus("wallet.getTransactionStatus"), @@ -40,6 +45,7 @@ enum class OmsSdkOperation( WalletIsValidTypedDataSignature("wallet.isValidTypedDataSignature"), WalletListAccess("wallet.listAccess"), WalletListAccessPage("wallet.listAccessPage"), + WalletListAccessPages("wallet.listAccessPages"), WalletListWallets("wallet.listWallets"), WalletRevokeAccess("wallet.revokeAccess"), WalletSendTransaction("wallet.sendTransaction"), @@ -48,9 +54,32 @@ enum class OmsSdkOperation( WalletSignTypedData("wallet.signTypedData"), WalletStartEmailAuth("wallet.startEmailAuth"), WalletStartOidcRedirectAuth("wallet.startOidcRedirectAuth"), + WalletTransactionStatus("wallet.transactionStatus"), WalletUseWallet("wallet.useWallet"), } +/** + * Remote OMS service that produced diagnostic failure details. + */ +enum class OmsUpstreamService { + Waas, + Indexer, +} + +/** + * Normalized diagnostic detail from a remote OMS service response or transport failure. + * + * Branch app behavior on [OmsSdkException.code]; use upstream details for logs and + * service-specific troubleshooting. + */ +data class OmsUpstreamError( + val service: OmsUpstreamService, + val name: String? = null, + val code: String? = null, + val message: String? = null, + val status: Int? = null, +) + /** * Base exception type thrown by public SDK APIs when a failure can be * categorized without exposing generated transport details. @@ -60,7 +89,8 @@ open class OmsSdkException( val operation: OmsSdkOperation? = null, val status: Int? = null, val txnId: String? = null, - val retryable: Boolean = false, + val retryable: Boolean? = null, + val upstreamError: OmsUpstreamError? = null, message: String, cause: Throwable? = null, ) : RuntimeException(message, cause) @@ -78,15 +108,19 @@ class OmsSessionException( ) class OmsRequestException( + code: OmsSdkErrorCode = OmsSdkErrorCode.RequestFailed, operation: OmsSdkOperation? = null, status: Int? = null, + retryable: Boolean? = status == null || status >= 500, + upstreamError: OmsUpstreamError? = null, message: String = "OMS request failed", cause: Throwable? = null, ) : OmsSdkException( - code = OmsSdkErrorCode.RequestFailed, + code = code, operation = operation, status = status, - retryable = status == null || status >= 500, + retryable = retryable, + upstreamError = upstreamError, message = message, cause = cause, ) @@ -94,26 +128,34 @@ class OmsRequestException( class OmsResponseException( operation: OmsSdkOperation? = null, status: Int? = null, + upstreamError: OmsUpstreamError? = null, message: String = "OMS response was invalid", cause: Throwable? = null, ) : OmsSdkException( code = OmsSdkErrorCode.InvalidResponse, operation = operation, status = status, + upstreamError = upstreamError, message = message, cause = cause, ) class OmsTransactionException( + code: OmsSdkErrorCode = OmsSdkErrorCode.TransactionStatusLookupFailed, operation: OmsSdkOperation? = null, + status: Int? = null, txnId: String? = null, + retryable: Boolean? = true, + upstreamError: OmsUpstreamError? = null, message: String = "Transaction status lookup failed", cause: Throwable? = null, ) : OmsSdkException( - code = OmsSdkErrorCode.TransactionStatusLookupFailed, + code = code, operation = operation, + status = status, txnId = txnId, - retryable = true, + retryable = retryable, + upstreamError = upstreamError, message = message, cause = cause, ) @@ -147,26 +189,19 @@ internal suspend fun runOmsOperation( ): T = try { block() - } catch (throwable: kotlinx.coroutines.CancellationException) { + } catch (throwable: CancellationException) { throw throwable } catch (throwable: OmsSdkException) { - if (throwable.operation == operation) { + if (throwable.operation == operation || throwable.isNestedTransactionBoundary()) { throw throwable } - throw OmsSdkException( - code = throwable.code, - operation = operation, - status = throwable.status, - txnId = throwable.txnId, - retryable = throwable.retryable, - message = throwable.message ?: operation.id, - cause = throwable, - ) + throw throwable.withOperation(operation) } catch (throwable: WebRpcError) { throw throwable.toOmsSdkException(operation) } catch (throwable: WebRpcTransportException) { throw OmsRequestException( operation = operation, + upstreamError = throwable.toWaasUpstreamError(), message = throwable.message ?: "WebRPC transport failed", cause = throwable, ) @@ -184,55 +219,63 @@ internal suspend fun runOmsOperation( ) } -private fun WebRpcError.toOmsSdkException(operation: OmsSdkOperation): OmsSdkException = - when (errorKind) { - ErrorKind.COMMITMENT_CONSUMED -> { - OmsSdkException( +private fun WebRpcError.toOmsSdkException(operation: OmsSdkOperation): OmsSdkException { + val normalizedStatus = normalizedStatus() + val upstreamError = toWaasUpstreamError(normalizedStatus) + val normalizedMessage = normalizedMessage() + + return when { + errorKind == ErrorKind.COMMITMENT_CONSUMED -> { + OmsRequestException( code = OmsSdkErrorCode.AuthCommitmentConsumed, operation = operation, - status = status, - message = message, + status = normalizedStatus, + retryable = false, + upstreamError = upstreamError, + message = normalizedMessage, cause = this, ) } - ErrorKind.WEBRPC_BAD_RESPONSE, - -> { - OmsResponseException( + isHttpWebRpcError(normalizedStatus) -> { + OmsRequestException( + code = OmsSdkErrorCode.HttpError, operation = operation, - status = status, - message = message, + status = normalizedStatus, + retryable = normalizedStatus != null && normalizedStatus >= 500, + upstreamError = upstreamError, + message = normalizedMessage, cause = this, ) } - ErrorKind.UNKNOWN -> { - if (code == ErrorKind.UNKNOWN.code) { - OmsResponseException( - operation = operation, - status = status, - message = message, - cause = this, - ) - } else { - OmsRequestException( - operation = operation, - status = status, - message = message, - cause = this, - ) - } + errorKind == ErrorKind.WEBRPC_BAD_RESPONSE || + (errorKind == ErrorKind.UNKNOWN && code == ErrorKind.UNKNOWN.code) -> { + OmsResponseException( + operation = operation, + status = normalizedStatus, + upstreamError = upstreamError, + message = normalizedMessage, + cause = this, + ) } else -> { OmsRequestException( operation = operation, - status = status, - message = message, + status = normalizedStatus, + retryable = normalizedStatus == null || normalizedStatus >= 500, + upstreamError = upstreamError, + message = normalizedMessage, cause = this, ) } } +} + +private fun OmsSdkException.isNestedTransactionBoundary(): Boolean = + code == OmsSdkErrorCode.TransactionExecutionUnconfirmed || + code == OmsSdkErrorCode.TransactionStatusLookupFailed internal fun Throwable.toOmsSdkException(operation: OmsSdkOperation): OmsSdkException = when (this) { @@ -240,15 +283,7 @@ internal fun Throwable.toOmsSdkException(operation: OmsSdkOperation): OmsSdkExce if (this.operation == operation) { this } else { - OmsSdkException( - code = code, - operation = operation, - status = status, - txnId = txnId, - retryable = retryable, - message = message ?: operation.id, - cause = this, - ) + withOperation(operation) } } @@ -259,6 +294,7 @@ internal fun Throwable.toOmsSdkException(operation: OmsSdkOperation): OmsSdkExce is WebRpcTransportException -> { OmsRequestException( operation = operation, + upstreamError = toWaasUpstreamError(), message = message ?: "WebRPC transport failed", cause = this, ) @@ -288,3 +324,130 @@ internal fun Throwable.toOmsSdkException(operation: OmsSdkOperation): OmsSdkExce ) } } + +private fun OmsSdkException.withOperation(operation: OmsSdkOperation): OmsSdkException = + when (this) { + is OmsRequestException -> { + OmsRequestException( + code = code, + operation = operation, + status = status, + retryable = retryable, + upstreamError = upstreamError, + message = message ?: operation.id, + cause = this, + ) + } + + is OmsResponseException -> { + OmsResponseException( + operation = operation, + status = status, + upstreamError = upstreamError, + message = message ?: operation.id, + cause = this, + ) + } + + is OmsTransactionException -> { + OmsTransactionException( + code = code, + operation = operation, + status = status, + txnId = txnId, + retryable = retryable, + upstreamError = upstreamError, + message = message ?: operation.id, + cause = this, + ) + } + + is OmsSessionException -> { + OmsSessionException( + code = code, + operation = operation, + message = message ?: operation.id, + cause = this, + ) + } + + is OmsWalletSelectionException -> { + OmsWalletSelectionException( + code = code, + operation = operation, + message = message ?: operation.id, + cause = this, + ) + } + + is OmsValidationException -> { + OmsValidationException( + operation = operation, + message = message ?: operation.id, + cause = this, + ) + } + + else -> { + OmsSdkException( + code = code, + operation = operation, + status = status, + txnId = txnId, + retryable = retryable, + upstreamError = upstreamError, + message = message ?: operation.id, + cause = this, + ) + } + } + +private fun WebRpcError.normalizedStatus(): Int? { + if (error == "WebrpcRequestFailed" && code == ErrorKind.WEBRPC_REQUEST_FAILED.code && status == 400) { + return null + } + return status +} + +private fun WebRpcError.toWaasUpstreamError(status: Int? = normalizedStatus()): OmsUpstreamError = + OmsUpstreamError( + service = OmsUpstreamService.Waas, + name = error, + code = normalizedCode(), + message = normalizedMessage(), + status = status, + ) + +private fun WebRpcError.normalizedCode(): String = + if (error == "WebrpcBadResponse" && code == ErrorKind.UNKNOWN.code) { + ErrorKind.WEBRPC_BAD_RESPONSE.code.toString() + } else { + code.toString() + } + +private fun WebRpcError.normalizedMessage(): String = + if (error == "WebrpcBadResponse" && code == ErrorKind.UNKNOWN.code) { + "bad response" + } else { + message + } + +private fun WebRpcTransportException.toWaasUpstreamError(): OmsUpstreamError = + OmsUpstreamError( + service = OmsUpstreamService.Waas, + name = "WebrpcRequestFailed", + code = ErrorKind.WEBRPC_REQUEST_FAILED.code.toString(), + message = message ?: "WebRPC transport failed", + status = null, + ) + +private fun WebRpcError.isHttpWebRpcError(status: Int?): Boolean = + status != null && + status >= 400 && + ( + errorKind == ErrorKind.WEBRPC_BAD_ROUTE || + errorKind == ErrorKind.WEBRPC_BAD_METHOD || + errorKind == ErrorKind.WEBRPC_BAD_REQUEST || + errorKind == ErrorKind.WEBRPC_BAD_RESPONSE || + error == "WebrpcBadResponse" + ) diff --git a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/indexer/IndexerClient.kt b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/indexer/IndexerClient.kt index d29bcf4..465525c 100644 --- a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/indexer/IndexerClient.kt +++ b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/indexer/IndexerClient.kt @@ -1,6 +1,12 @@ package com.omsclient.kotlin_sdk.indexer import com.omsclient.kotlin_sdk.Network +import com.omsclient.kotlin_sdk.OmsRequestException +import com.omsclient.kotlin_sdk.OmsResponseException +import com.omsclient.kotlin_sdk.OmsSdkErrorCode +import com.omsclient.kotlin_sdk.OmsSdkOperation +import com.omsclient.kotlin_sdk.OmsUpstreamError +import com.omsclient.kotlin_sdk.OmsUpstreamService import com.omsclient.kotlin_sdk.models.TokenBalance import com.omsclient.kotlin_sdk.models.TokenBalancesPage import com.omsclient.kotlin_sdk.models.TokenBalancesPageRequest @@ -10,6 +16,8 @@ import com.omsclient.kotlin_sdk.models.TokenMetadata import com.omsclient.kotlin_sdk.models.TokenMetadataAsset import com.omsclient.kotlin_sdk.network.OMSClientEnvironment import com.omsclient.kotlin_sdk.network.OMSClientHttpClient +import com.omsclient.kotlin_sdk.network.OMSClientHttpResponse +import com.omsclient.kotlin_sdk.network.OMSClientJson import com.omsclient.kotlin_sdk.network.arrayOrEmpty import com.omsclient.kotlin_sdk.network.boolean import com.omsclient.kotlin_sdk.network.int @@ -17,10 +25,13 @@ import com.omsclient.kotlin_sdk.network.long import com.omsclient.kotlin_sdk.network.objectOrNull import com.omsclient.kotlin_sdk.network.parseJsonObject import com.omsclient.kotlin_sdk.network.string +import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject @@ -39,9 +50,11 @@ class IndexerClient internal constructor( includeMetadata: Boolean, page: TokenBalancesPageRequest = TokenBalancesPageRequest(), ): TokenBalancesResult { + val operation = OmsSdkOperation.IndexerGetTokenBalances val response = - transport.postJson( - baseUrl = environment.indexerUrlFor(network), + postIndexerJson( + operation = operation, + network = network, path = "/GetTokenBalances", body = buildJsonObject { @@ -54,10 +67,9 @@ class IndexerClient internal constructor( put("accountAddress", walletAddress) put("includeMetadata", includeMetadata) }.toString(), - headers = defaultHeaders(), ) - val root = parseJsonObject(response.body) + val root = parseIndexerJsonObject(response, operation) val pageObject = root.objectOrNull("page") val page = pageObject?.let { @@ -88,18 +100,19 @@ class IndexerClient internal constructor( network: Network, walletAddress: String, ): TokenBalance? { + val operation = OmsSdkOperation.IndexerGetNativeTokenBalance val response = - transport.postJson( - baseUrl = environment.indexerUrlFor(network), + postIndexerJson( + operation = operation, + network = network, path = "/GetNativeTokenBalance", body = buildJsonObject { put("accountAddress", walletAddress) }.toString(), - headers = defaultHeaders(), ) - val balanceObject = parseJsonObject(response.body).objectOrNull("balance") ?: return null + val balanceObject = parseIndexerJsonObject(response, operation).objectOrNull("balance") ?: return null return TokenBalance( contractType = "NATIVE", contractAddress = null, @@ -115,6 +128,73 @@ class IndexerClient internal constructor( ) } + private suspend fun postIndexerJson( + operation: OmsSdkOperation, + network: Network, + path: String, + body: String, + ): OMSClientHttpResponse { + val response = + try { + transport.postJsonWithStatus( + baseUrl = environment.indexerUrlFor(network), + path = path, + body = body, + headers = defaultHeaders(), + ) + } catch (throwable: CancellationException) { + throw throwable + } catch (throwable: Throwable) { + throw OmsRequestException( + operation = operation, + upstreamError = throwable.toIndexerUpstreamError(), + message = throwable.message ?: "${operation.id} request failed", + cause = throwable, + ) + } + + if (response.statusCode !in 200..299) { + val parsed = parseJsonOrText(response.body) + val message = indexerResponseMessage(parsed, operation, response.statusCode) + throw OmsRequestException( + code = OmsSdkErrorCode.HttpError, + operation = operation, + status = response.statusCode, + retryable = response.statusCode >= 500, + upstreamError = + parsed.toIndexerUpstreamError( + status = response.statusCode, + fallbackMessage = message, + ), + message = message, + ) + } + + return response + } + + private fun parseIndexerJsonObject( + response: OMSClientHttpResponse, + operation: OmsSdkOperation, + ): JsonObject = + try { + parseJsonObject(response.body) + } catch (throwable: Throwable) { + val message = "Invalid JSON response from ${operation.id}" + throw OmsResponseException( + operation = operation, + status = response.statusCode, + upstreamError = + OmsUpstreamError( + service = OmsUpstreamService.Indexer, + message = message, + status = response.statusCode, + ), + message = message, + cause = throwable, + ) + } + private fun defaultHeaders(): Map = mapOf( OMSClientEnvironment.accessKeyHeaderName to publishableKey, @@ -205,4 +285,50 @@ class IndexerClient internal constructor( ) private fun JsonObject.toMap(): Map = entries.associate { it.key to it.value } + + private fun parseJsonOrText(body: String): Any = runCatching { OMSClientJson.json.parseToJsonElement(body) }.getOrElse { body } + + private fun indexerResponseMessage( + payload: Any, + operation: OmsSdkOperation, + status: Int, + ): String = + when (payload) { + is JsonObject -> payload.string("message") ?: payload.string("msg") + else -> null + } ?: "${operation.id} failed with HTTP $status" + + private fun Any.toIndexerUpstreamError( + status: Int, + fallbackMessage: String, + ): OmsUpstreamError = + when (this) { + is JsonObject -> { + OmsUpstreamError( + service = OmsUpstreamService.Indexer, + name = string("name") ?: string("error"), + code = stringOrNumber("code"), + message = string("message") ?: string("msg") ?: fallbackMessage, + status = status, + ) + } + + else -> { + OmsUpstreamError( + service = OmsUpstreamService.Indexer, + message = fallbackMessage, + status = status, + ) + } + } + + private fun Throwable.toIndexerUpstreamError(): OmsUpstreamError = + OmsUpstreamError( + service = OmsUpstreamService.Indexer, + name = javaClass.simpleName, + message = message, + status = null, + ) + + private fun JsonObject.stringOrNumber(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull } diff --git a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/session/OMSClientSession.kt b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/session/OMSClientSession.kt index 9f8e8de..88bc424 100644 --- a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/session/OMSClientSession.kt +++ b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/session/OMSClientSession.kt @@ -190,7 +190,7 @@ internal class OMSClientSession( fun requireSnapshot(): OMSClientSessionSnapshot = state.snapshot() - ?: error("No active OMS Client session") + ?: error("No active wallet session") fun requirePendingAuth(): OMSClientPendingAuthSnapshot = when (val current = state) { diff --git a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt index 79bc734..51cc448 100644 --- a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt +++ b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt @@ -476,7 +476,16 @@ class WalletClient internal constructor( code: String, sessionLifetimeSeconds: UInt, ): WalletAuthCompletion { - val snapshot = session.requirePendingAuth() + val snapshot = + try { + session.requirePendingAuth() + } catch (throwable: IllegalStateException) { + throw OmsSessionException( + operation = OmsSdkOperation.WalletCompleteEmailAuth, + message = "No pending email auth attempt", + cause = throwable, + ) + } return gateway.completeEmailAuth( verifier = snapshot.verifier, challenge = snapshot.challenge, @@ -957,16 +966,18 @@ class WalletClient internal constructor( */ fun listAccessPages(pageSize: UInt? = null): Flow = flow { - var cursor: String? = null - do { - val response = - listAccessPage( - pageSize = pageSize, - cursor = cursor, - ) - emit(response) - cursor = response.page?.cursor?.takeIf { it.isNotBlank() } - } while (cursor != null) + runOmsOperation(OmsSdkOperation.WalletListAccessPages) { + var cursor: String? = null + do { + val response = + requestListAccessPage( + pageSize = pageSize, + cursor = cursor, + ) + emit(response) + cursor = response.page?.cursor?.takeIf { it.isNotBlank() } + } while (cursor != null) + } } /** @@ -977,14 +988,21 @@ class WalletClient internal constructor( cursor: String? = null, ): ListAccessResponse = runOmsOperation(OmsSdkOperation.WalletListAccessPage) { - session.requireSnapshot() - requireActiveCredential() - gateway.listAccessPage( - walletId = requireWalletId(), - page = accessPage(pageSize, cursor), - ) + requestListAccessPage(pageSize, cursor) } + private suspend fun requestListAccessPage( + pageSize: UInt?, + cursor: String?, + ): ListAccessResponse { + session.requireSnapshot() + requireActiveCredential() + return gateway.listAccessPage( + walletId = requireWalletId(), + page = accessPage(pageSize, cursor), + ) + } + /** * Returns an ID token for the currently selected wallet. */ @@ -1196,7 +1214,24 @@ class WalletClient internal constructor( ) } } - val executed = gateway.execute(prepared.txnId, feeOption) + val executed = + try { + gateway.execute(prepared.txnId, feeOption) + } catch (throwable: CancellationException) { + throw throwable + } catch (throwable: Throwable) { + val sdkError = throwable.toOmsSdkException(OmsSdkOperation.WalletExecute) + throw OmsTransactionException( + code = OmsSdkErrorCode.TransactionExecutionUnconfirmed, + operation = OmsSdkOperation.WalletExecute, + status = sdkError.status, + txnId = prepared.txnId, + retryable = false, + upstreamError = sdkError.upstreamError, + message = "Transaction execution failed before status could be confirmed", + cause = sdkError, + ) + } if (!waitForStatus) { return ClientSendTransactionResponse( txnId = prepared.txnId, @@ -1343,11 +1378,15 @@ class WalletClient internal constructor( } catch (throwable: CancellationException) { throw throwable } catch (throwable: Throwable) { + val sdkError = throwable.toOmsSdkException(OmsSdkOperation.WalletTransactionStatus) throw OmsTransactionException( - operation = OmsSdkOperation.WalletGetTransactionStatus, + operation = OmsSdkOperation.WalletTransactionStatus, + status = sdkError.status, txnId = txnId, - message = throwable.message ?: "Transaction status lookup failed", - cause = throwable, + retryable = true, + upstreamError = sdkError.upstreamError, + message = "Transaction was submitted, but status polling failed", + cause = sdkError, ) } completedStatusPolls += 1 diff --git a/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/network/ServiceClientsTest.kt b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/network/ServiceClientsTest.kt index fd98750..a2f5445 100644 --- a/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/network/ServiceClientsTest.kt +++ b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/network/ServiceClientsTest.kt @@ -5,6 +5,7 @@ import com.omsclient.kotlin_sdk.OMSClient import com.omsclient.kotlin_sdk.OmsSdkErrorCode import com.omsclient.kotlin_sdk.OmsSdkException import com.omsclient.kotlin_sdk.OmsSdkOperation +import com.omsclient.kotlin_sdk.OmsUpstreamService import com.omsclient.kotlin_sdk.indexer.IndexerClient import com.omsclient.kotlin_sdk.session.OMSClientSessionSnapshot import kotlinx.coroutines.runBlocking @@ -366,6 +367,11 @@ class ServiceClientsTest { assertEquals("endpoint error", failure.message) assertEquals(400, failure.status) assertFalse(requireNotNull(failure.message).contains("sensitive backend context")) + assertEquals(OmsUpstreamService.Waas, failure.upstreamError?.service) + assertEquals("WebrpcEndpoint", failure.upstreamError?.name) + assertEquals("-999", failure.upstreamError?.code) + assertEquals("endpoint error", failure.upstreamError?.message) + assertEquals(400, failure.upstreamError?.status) } @Test @@ -406,6 +412,11 @@ class ServiceClientsTest { assertEquals(OmsSdkOperation.WalletIsValidMessageSignature, failure.operation) assertEquals("Backend rollout error", failure.message) assertEquals(409, failure.status) - assertFalse(failure.retryable) + assertEquals(false, failure.retryable) + assertEquals(OmsUpstreamService.Waas, failure.upstreamError?.service) + assertEquals("NewBackendError", failure.upstreamError?.name) + assertEquals("7999", failure.upstreamError?.code) + assertEquals("Backend rollout error", failure.upstreamError?.message) + assertEquals(409, failure.upstreamError?.status) } } diff --git a/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletEmailAuthTest.kt b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletEmailAuthTest.kt index d85c80b..31632cf 100644 --- a/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletEmailAuthTest.kt +++ b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletEmailAuthTest.kt @@ -3,6 +3,8 @@ package com.omsclient.kotlin_sdk.wallet import com.omsclient.kotlin_sdk.OMSClientSessionLoginType import com.omsclient.kotlin_sdk.OmsSdkErrorCode import com.omsclient.kotlin_sdk.OmsSdkException +import com.omsclient.kotlin_sdk.OmsSdkOperation +import com.omsclient.kotlin_sdk.OmsUpstreamService import com.omsclient.kotlin_sdk.internal.generated.waas.AuthMode import com.omsclient.kotlin_sdk.internal.generated.waas.CommitVerifierRequest import com.omsclient.kotlin_sdk.internal.generated.waas.CompleteAuthRequest @@ -150,6 +152,47 @@ class WalletEmailAuthTest { assertEquals(1, redirectStore.clearCalls) } + @Test + fun startEmailAuthRequestFailedNormalizesGeneratedStatus400ToNoStatus() = + runBlocking { + server.enqueue( + MockResponse + .Builder() + .code(400) + .body("""{"error":"WebrpcRequestFailed","code":-1,"msg":"request failed","status":400}""") + .build(), + ) + + val client = + WalletClient( + publishableKey = "test-publishable-key", + projectId = "test-project-id", + environment = + OMSClientEnvironment( + walletApiUrl = server.url("/rpc/Wallet/").toString(), + ), + transport = OMSClientHttpClient(), + sessionStore = InMemorySessionStore(), + credentialSigner = TrackingCredentialSigner(nonceValue = "1710000100"), + ) + + val failure = + runCatching { + client.startEmailAuth("user@example.com") + }.exceptionOrNull() as? OmsSdkException + + requireNotNull(failure) + assertEquals(OmsSdkErrorCode.RequestFailed, failure.code) + assertEquals(OmsSdkOperation.WalletStartEmailAuth, failure.operation) + assertEquals(null, failure.status) + assertEquals(true, failure.retryable) + assertEquals(OmsUpstreamService.Waas, failure.upstreamError?.service) + assertEquals("WebrpcRequestFailed", failure.upstreamError?.name) + assertEquals("-1", failure.upstreamError?.code) + assertEquals("request failed", failure.upstreamError?.message) + assertEquals(null, failure.upstreamError?.status) + } + @Test fun startEmailAuthReplacesActiveWalletSessionWithPendingAuth() = runBlocking { @@ -1385,6 +1428,17 @@ class WalletEmailAuthTest { requireNotNull(server.takeRequest()) requireNotNull(server.takeRequest()) assertNotNull(firstFailure) + assertTrue(firstFailure is OmsSdkException) + firstFailure as OmsSdkException + assertEquals(OmsSdkErrorCode.RequestFailed, firstFailure.code) + assertEquals(OmsSdkOperation.WalletCompleteEmailAuth, firstFailure.operation) + assertEquals(401, firstFailure.status) + assertEquals(false, firstFailure.retryable) + assertEquals(OmsUpstreamService.Waas, firstFailure.upstreamError?.service) + assertEquals("Unauthorized", firstFailure.upstreamError?.name) + assertEquals("4001", firstFailure.upstreamError?.code) + assertEquals("invalid code", firstFailure.upstreamError?.message) + assertEquals(401, firstFailure.upstreamError?.status) assertTrue(client.hasPendingSignIn) assertEquals("challenge", afterFailure?.challenge) assertEquals("verifier-123", afterFailure?.verifier) diff --git a/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletOidcRedirectAuthTest.kt b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletOidcRedirectAuthTest.kt index 686e2c7..df8192a 100644 --- a/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletOidcRedirectAuthTest.kt +++ b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/wallet/WalletOidcRedirectAuthTest.kt @@ -19,7 +19,6 @@ import mockwebserver3.MockResponse import mockwebserver3.MockWebServer import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -904,7 +903,7 @@ class WalletOidcRedirectAuthTest { assertEquals(OmsSdkOperation.WalletHandleOidcRedirectCallback, failure.operation) assertEquals(400, failure.status) assertEquals("Bad callback", failure.message) - assertFalse(failure.retryable) + assertEquals(false, failure.retryable) assertEquals("/rpc/Wallet/CommitVerifier", commitRequest.target) assertEquals("/rpc/Wallet/CompleteAuth", completeAuthRequest.target) assertNull(client.snapshotSession()) From 4d371bd5022c361d87bfea0333b4e48459b1b0a5 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 12 Jun 2026 16:08:21 +0300 Subject: [PATCH 2/4] Add public error contract matrix --- AGENTS.md | 15 + README.md | 24 + TESTING.md | 23 + docs/api.md | 46 +- docs/error-contracts.md | 39 + .../kotlin_sdk/PublicErrorContractsTest.kt | 1428 +++++++++++++++++ 6 files changed, 1571 insertions(+), 4 deletions(-) create mode 100644 docs/error-contracts.md create mode 100644 oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/PublicErrorContractsTest.kt diff --git a/AGENTS.md b/AGENTS.md index 62a83d2..3ead426 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,6 +218,21 @@ and the execution command reference. platform services. Prefer deterministic local fixtures for signing vectors. - Add regression tests for bug fixes before or alongside the fix when practical. +### Public Error Contract Tests + +- Follow the detailed rules in `TESTING.md` before adding or updating public + error contract tests. +- Use `docs/error-contracts.md` as the audit matrix for public SDK error + surfaces, recovery semantics, `upstreamError` expectations, and owning tests. +- Keep serialized public error shape assertions centralized in + `PublicErrorContractsTest`; focused tests should cover behavior or edge cases + without duplicating the full matrix. +- Exercise real public runtime APIs and mock only external boundaries. +- Assert stable public fields only; do not assert raw `cause`, stacks, generated + internals, headers, timestamps, or full backend payloads as public contract. +- Include `upstreamError` only when the path crosses a remote service response + or transport boundary. SDK-local failures should not expose upstream details. + ## Generated Files and External Artifacts - `oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/generated/waas/WaasWalletClient.kt` diff --git a/README.md b/README.md index 5b7053f..d3ede92 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,30 @@ Transaction values are raw base-unit integers. Use `parseUnits` to convert human-entered decimal values before sending. Import the helpers from `com.omsclient.kotlin_sdk.utils`. +## Errors + +Public SDK APIs throw `OmsSdkException` subclasses with stable fields such as +`code`, `operation`, `status`, nullable `retryable`, and `txnId`. When a failure comes +from a remote OMS service response or transport failure, the error also includes +`upstreamError` with normalized WaaS or indexer details for logging and +service-specific troubleshooting. Application logic should usually branch on the +SDK-level `code`. + +For transaction writes, `TransactionExecutionUnconfirmed` means the SDK has a +`txnId` from preparation, but the execute request failed before the SDK could +confirm whether the transaction was submitted; do not blindly resend the same +write. `TransactionStatusLookupFailed` means the transaction was submitted but +status polling failed, so retry status lookup with the returned `txnId`. +`retryable` describes the failed SDK operation, not the whole user intent. + +```kotlin +try { + client.wallet.startEmailAuth("user@example.com") +} catch (error: OmsSdkException) { + println("${error.code} ${error.operation?.id} ${error.upstreamError}") +} +``` + For raw token amount formatting and parsing: ```kotlin diff --git a/TESTING.md b/TESTING.md index 6a23bd4..a0fe4f4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -44,6 +44,29 @@ Key subdirectories: tests that only assert private call ordering. - Add a regression test for bug fixes before or alongside the fix when practical. +### Public Error Contract Tests + +- Use `docs/error-contracts.md` as the audit matrix for public SDK error surfaces, recovery + semantics, `upstreamError` expectations, and owning tests. +- Keep serialized public error shape assertions centralized in + `PublicErrorContractsTest`; focused tests should cover local behavior or edge cases without + duplicating the full public-field matrix. +- Exercise real public runtime APIs such as `client.wallet.*`, `client.indexer.*`, auth result + actions, and public exception classes. +- Mock only external boundaries: network responses, time, randomness, Android platform services, or + signer behavior. +- Assert stable public fields only: exception class, `code`, `operation`, `message`, `status`, + `retryable`, `txnId`, and `upstreamError`. +- Do not assert raw `cause`, stacks, generated WebRPC internals, request headers, timestamps, or + full backend payloads as public error contract fields. +- Include `upstreamError` only when the tested path truthfully crosses a remote service or transport + boundary. SDK-local validation, session, and wallet-selection failures should assert no upstream + details. +- Treat `code` and `operation` as stronger contract fields than `message`. Message changes are + allowed when intentional, but they should be reviewed as user-visible API/UX changes. +- `retryable` describes the failed SDK operation, not the whole user intent. A retryable status + lookup failure does not mean the original transaction write should be blindly resent. + ## Execution Summary | Goal | Command | diff --git a/docs/api.md b/docs/api.md index 272e496..773a8a3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -566,6 +566,7 @@ enum class OmsSdkErrorCode { WalletSelectionStale, WalletSelectionUnavailable, WalletSelectionInFlight, + TransactionExecutionUnconfirmed, TransactionStatusLookupFailed, ValidationError, } @@ -577,20 +578,39 @@ open class OmsSdkException( val operation: OmsSdkOperation?, val status: Int?, val txnId: String?, - val retryable: Boolean, + val retryable: Boolean?, + val upstreamError: OmsUpstreamError?, ) : RuntimeException ``` +```kotlin +enum class OmsUpstreamService { + Waas, + Indexer, +} + +data class OmsUpstreamError( + val service: OmsUpstreamService, + val name: String?, + val code: String?, + val message: String?, + val status: Int?, +) +``` + ```kotlin enum class OmsSdkOperation( val id: String, ) { - PendingWalletSelection("pendingWalletSelection"), - PendingWalletSelectionCreateAndSelectWallet("pendingWalletSelection.createAndSelectWallet"), - PendingWalletSelectionSelectWallet("pendingWalletSelection.selectWallet"), + PendingWalletSelection("wallet.pendingWalletSelection"), + PendingWalletSelectionCreateAndSelectWallet("wallet.pendingWalletSelection.createAndSelectWallet"), + PendingWalletSelectionSelectWallet("wallet.pendingWalletSelection.selectWallet"), + IndexerGetNativeTokenBalance("indexer.getNativeTokenBalance"), + IndexerGetTokenBalances("indexer.getTokenBalances"), WalletCallContract("wallet.callContract"), WalletCompleteEmailAuth("wallet.completeEmailAuth"), WalletCreateWallet("wallet.createWallet"), + WalletExecute("wallet.execute"), WalletGetIdToken("wallet.getIdToken"), WalletHandleOidcRedirectCallback("wallet.handleOidcRedirectCallback"), WalletGetTransactionStatus("wallet.getTransactionStatus"), @@ -598,6 +618,7 @@ enum class OmsSdkOperation( WalletIsValidTypedDataSignature("wallet.isValidTypedDataSignature"), WalletListAccess("wallet.listAccess"), WalletListAccessPage("wallet.listAccessPage"), + WalletListAccessPages("wallet.listAccessPages"), WalletListWallets("wallet.listWallets"), WalletRevokeAccess("wallet.revokeAccess"), WalletSendTransaction("wallet.sendTransaction"), @@ -606,6 +627,7 @@ enum class OmsSdkOperation( WalletSignTypedData("wallet.signTypedData"), WalletStartEmailAuth("wallet.startEmailAuth"), WalletStartOidcRedirectAuth("wallet.startOidcRedirectAuth"), + WalletTransactionStatus("wallet.transactionStatus"), WalletUseWallet("wallet.useWallet"), } ``` @@ -614,6 +636,22 @@ enum class OmsSdkOperation( error codes newer than the generated WaaS client. `InvalidResponse` is reserved for malformed or unparseable responses. +`upstreamError` is normalized diagnostic detail from a remote OMS service response +or transport failure. Use SDK-level `code` for app branching; use +`upstreamError` for logging and service-specific troubleshooting. SDK-local +validation, session, and wallet-selection failures do not include upstream +details. + +`TransactionExecutionUnconfirmed` means transaction preparation succeeded and +the SDK has a `txnId`, but the execute request failed before the SDK could +confirm whether the transaction was submitted. Do not blindly resend the same +write solely because the upstream failure looked temporary. + +`TransactionStatusLookupFailed` means the transaction was submitted, but +post-submit status polling failed. Retry by calling `getTransactionStatus` with +the returned `txnId`; `retryable` describes that status lookup operation, not +the original write. + ## Public Models ```kotlin diff --git a/docs/error-contracts.md b/docs/error-contracts.md new file mode 100644 index 0000000..f380291 --- /dev/null +++ b/docs/error-contracts.md @@ -0,0 +1,39 @@ +# Public Error Contracts + +This matrix is the audit surface for SDK error behavior. It documents which public runtime +surfaces can fail, what error shape users should see, what recovery decision the error supports, +whether `upstreamError` should be present, and which tests own the contract. + +## Terms + +- `upstreamError` is normalized diagnostic detail from a remote OMS service response or transport + failure. It is for logging and service-specific troubleshooting. Application logic should usually + branch on the SDK-level `code`. +- `retryable` is nullable and describes the failed SDK operation when retry semantics apply. A + retryable status lookup failure does not mean the original transaction write should be blindly + resent. +- `TransactionExecutionUnconfirmed` means transaction preparation succeeded, but the execute request + failed before the SDK could confirm whether the transaction was submitted. Do not blindly resend + the same write solely because the upstream failure looked temporary. +- `TransactionStatusLookupFailed` means the transaction was submitted, but post-submit status + polling failed. Retry by checking transaction status with the returned `txnId`. + +## SDK Matrix + +| Public surface | Failure family | User-facing error | Recovery meaning | `upstreamError` | Covering test | +|---|---|---|---|---|---| +| `client.wallet.startEmailAuth`, representative WaaS methods | WaaS transport failure | `OmsRequestException`, `RequestFailed`, operation-specific, retryable when transport/5xx | Retry the same read/auth request when appropriate | Present | `PublicErrorContractsTest` | +| `client.wallet.completeEmailAuth` | WaaS domain error | SDK-specific code such as `AuthCommitmentConsumed` | Follow the SDK code; for consumed commitments, restart auth | Present | `PublicErrorContractsTest` | +| `client.wallet.*`, representative WaaS methods | WaaS HTTP error | `OmsRequestException`, `HttpError`, status, retryable for 5xx | Use SDK code/status for branching; log upstream detail | Present | `PublicErrorContractsTest` | +| `client.wallet.completeEmailAuth` and pending wallet selection actions | Local auth/session/selection state | `OmsSessionException` or `OmsWalletSelectionException` | Fix local flow state or restart auth; do not look for backend diagnostics | Absent | `PublicErrorContractsTest` | +| OIDC redirect/id-token auth methods | Local OIDC config, callback, storage, or state mismatch | `OmsSessionException`, `OmsValidationException`, or `OidcRedirectAuthResult.Failed` with `OmsSdkException` | Fix redirect config/state or restart OIDC flow | Absent | `PublicErrorContractsTest` | +| Protected wallet methods: `getIdToken`, `signMessage`, `signTypedData`, `sendTransaction`, `callContract`, `getTransactionStatus`, `listAccess`, `listAccessPages`, `revokeAccess` | Missing, expired, or stale local session | `OmsSessionException` | Authenticate again or recover local session; no remote request was made | Absent | `PublicErrorContractsTest` | +| `client.wallet.signMessage`, `signTypedData`, `getIdToken`, `sendTransaction`, `callContract` | SDK-local validation or fee-selection failure | `OmsValidationException` | Correct parameters or local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTest` | +| `client.wallet.isValidMessageSignature`, `isValidTypedDataSignature` | WaaS validation backend failure | `OmsRequestException` or `OmsResponseException` with validation operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTest` | +| `client.wallet.sendTransaction`, `callContract` | Execute request fails after prepare | `OmsTransactionException`, `TransactionExecutionUnconfirmed`, `retryable = false`, `txnId` | Do not blindly resend the write; preserve `txnId` and upstream detail for diagnostics | Present when execute crossed transport/upstream boundary | `PublicErrorContractsTest` | +| `client.wallet.sendTransaction`, `callContract` | Submitted transaction status polling fails | `OmsTransactionException`, `TransactionStatusLookupFailed`, `retryable = true`, `txnId` | Retry status lookup, not the original write | Present when polling crossed transport/upstream boundary | `PublicErrorContractsTest` | +| `client.wallet.getTransactionStatus` | Direct status lookup backend failure | `OmsRequestException` or `OmsResponseException` with status operation | Retry status lookup or surface backend status to the user | Present | `PublicErrorContractsTest` | +| `client.wallet.listAccess`, `listAccessPages`, `revokeAccess` | WaaS access backend failure | `OmsRequestException` or `OmsResponseException` with access operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTest` | +| `client.indexer.getTokenBalances`, `getNativeTokenBalance` | Indexer backend, transport, malformed JSON, or malformed payload | `OmsRequestException` or `OmsResponseException` with indexer operation | Retry based on SDK code/status; log upstream detail | Present for remote/transport response failures | `PublicErrorContractsTest` | +| `client.indexer.getTokenBalances`, `getNativeTokenBalance` | Indexer non-JSON HTTP body | `OmsRequestException`, `HttpError`, sanitized message | Do not expose raw upstream HTML/text bodies; log normalized detail | Present, sanitized | `PublicErrorContractsTest` | +| Public `OmsSdkException` classes and upstream fields | Error class field contract | Stable public fields on constructed errors | Use only when the error class/helper is the unit under test | As constructed | `PublicErrorContractsTest` | diff --git a/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/PublicErrorContractsTest.kt b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/PublicErrorContractsTest.kt new file mode 100644 index 0000000..a836e0b --- /dev/null +++ b/oms-client-kotlin-sdk/src/test/java/com/omsclient/kotlin_sdk/PublicErrorContractsTest.kt @@ -0,0 +1,1428 @@ +package com.omsclient.kotlin_sdk + +import com.omsclient.kotlin_sdk.indexer.IndexerClient +import com.omsclient.kotlin_sdk.models.AbiArg +import com.omsclient.kotlin_sdk.models.SendTransactionRequest +import com.omsclient.kotlin_sdk.network.OMSClientEnvironment +import com.omsclient.kotlin_sdk.network.OMSClientHttpClient +import com.omsclient.kotlin_sdk.session.OMSClientSessionSnapshot +import com.omsclient.kotlin_sdk.wallet.CompleteAuthResult +import com.omsclient.kotlin_sdk.wallet.CredentialSigner +import com.omsclient.kotlin_sdk.wallet.InMemoryOidcRedirectAuthStore +import com.omsclient.kotlin_sdk.wallet.InMemorySessionStore +import com.omsclient.kotlin_sdk.wallet.OidcProviderConfig +import com.omsclient.kotlin_sdk.wallet.OidcRedirectAuthResult +import com.omsclient.kotlin_sdk.wallet.TEST_CREDENTIAL_ID +import com.omsclient.kotlin_sdk.wallet.TrackingCredentialSigner +import com.omsclient.kotlin_sdk.wallet.WalletClient +import com.omsclient.kotlin_sdk.wallet.WalletSelectionBehavior +import com.omsclient.kotlin_sdk.wallet.WalletSigningAlgorithm +import com.omsclient.kotlin_sdk.wallet.activeSessionSnapshot +import com.omsclient.kotlin_sdk.wallet.completeAuthResponseBody +import com.omsclient.kotlin_sdk.wallet.walletFixture +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.math.BigInteger +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class PublicErrorContractsTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun snapshotsWaasTransportFailuresWithUpstreamDetails() = + runBlocking { + val client = createOmsClient(okHttpClient = failingOkHttpClient("request failed")) + + assertEquals( + error( + name = "OmsRequestException", + code = "RequestFailed", + operation = "wallet.startEmailAuth", + message = "WebRPC request failed", + retryable = true, + upstreamError = + upstream( + service = "Waas", + name = "WebrpcRequestFailed", + code = "-1", + message = "WebRPC request failed", + ), + ), + publicError { + client.wallet.startEmailAuth("user@example.com") + }, + ) + } + + @Test + fun snapshotsWaasDomainErrorsWithUpstreamDetails() = + runBlocking { + enqueueJson("""{"verifier":"verifier-1","loginHint":"user@example.com","challenge":"challenge-1"}""") + server.enqueue( + MockResponse + .Builder() + .code(400) + .body( + """ + { + "error": "CommitmentConsumed", + "code": 7008, + "msg": "The authentication commitment has already been used", + "status": 400 + } + """.trimIndent(), + ).build(), + ) + + val client = createOmsClient() + client.wallet.startEmailAuth("user@example.com") + + assertEquals( + error( + name = "OmsRequestException", + code = "AuthCommitmentConsumed", + operation = "wallet.completeEmailAuth", + message = "The authentication commitment has already been used", + status = 400, + retryable = false, + upstreamError = + upstream( + service = "Waas", + name = "CommitmentConsumed", + code = "7008", + message = "The authentication commitment has already been used", + status = 400, + ), + ), + publicError { + client.wallet.completeEmailAuth("123456") + }, + ) + } + + @Test + fun snapshotsWaasHttpResponsesWithUpstreamDetails() = + runBlocking { + server.enqueue( + MockResponse + .Builder() + .code(502) + .body("Bad Gateway") + .build(), + ) + + val client = createOmsClient() + + assertEquals( + error( + name = "OmsRequestException", + code = "HttpError", + operation = "wallet.startEmailAuth", + message = "bad response", + status = 502, + retryable = true, + upstreamError = + upstream( + service = "Waas", + name = "WebrpcBadResponse", + code = "-5", + message = "bad response", + status = 502, + ), + ), + publicError { + client.wallet.startEmailAuth("user@example.com") + }, + ) + } + + @Test + fun snapshotsEmailAuthCompletionLocalStateErrors() = + runBlocking { + val client = createOmsClient() + + assertEquals( + listOf( + labeled( + "wallet.completeEmailAuth.noPendingAuth", + error( + name = "OmsSessionException", + code = "SessionMissing", + operation = "wallet.completeEmailAuth", + message = "No pending email auth attempt", + ), + ), + labeled( + "wallet.completeEmailAuth.invalidLifetime", + error( + name = "OmsValidationException", + code = "ValidationError", + operation = "wallet.completeEmailAuth", + message = "sessionLifetimeSeconds must be a positive whole number", + ), + ), + ), + publicErrors( + "wallet.completeEmailAuth.noPendingAuth" to { + client.wallet.completeEmailAuth("123456") + }, + "wallet.completeEmailAuth.invalidLifetime" to { + client.wallet.completeEmailAuth( + code = "123456", + sessionLifetimeSeconds = 0L, + ) + }, + ), + ) + } + + @Test + fun snapshotsPendingWalletSelectionLocalStateErrors() = + runBlocking { + val errors = mutableListOf() + + enqueueJson("""{"verifier":"verifier-unavailable","loginHint":"user@example.com","challenge":"challenge"}""") + enqueueJson(completeAuthResponseBody(listOf(walletFixture("wallet-1", "0x1111111111111111111111111111111111111111")))) + val unavailableClient = createOmsClient() + unavailableClient.wallet.startEmailAuth("user@example.com") + val unavailableSelection = + ( + unavailableClient.wallet.completeEmailAuth( + code = "123456", + walletSelection = WalletSelectionBehavior.Manual, + ) as CompleteAuthResult.WalletSelection + ).pendingSelection + errors += + labeled( + "wallet.pendingWalletSelection.selectWallet.unavailable", + publicError { + unavailableSelection.selectWallet("wallet-missing") + }, + ) + + enqueueJson("""{"verifier":"old-verifier","loginHint":"old@example.com","challenge":"old-challenge"}""") + enqueueJson(completeAuthResponseBody(listOf(walletFixture("wallet-old", "0x2222222222222222222222222222222222222222")))) + enqueueJson("""{"verifier":"new-verifier","loginHint":"new@example.com","challenge":"new-challenge"}""") + enqueueJson(completeAuthResponseBody(listOf(walletFixture("wallet-new", "0x3333333333333333333333333333333333333333")))) + val staleClient = createOmsClient() + staleClient.wallet.startEmailAuth("old@example.com") + val staleSelection = + ( + staleClient.wallet.completeEmailAuth( + code = "111111", + walletSelection = WalletSelectionBehavior.Manual, + ) as CompleteAuthResult.WalletSelection + ).pendingSelection + staleClient.wallet.startEmailAuth("new@example.com") + staleClient.wallet.completeEmailAuth( + code = "222222", + walletSelection = WalletSelectionBehavior.Manual, + ) + errors += + labeled( + "wallet.pendingWalletSelection.selectWallet.stale", + publicError { + staleSelection.selectWallet("wallet-old") + }, + ) + errors += + labeled( + "wallet.pendingWalletSelection.createAndSelectWallet.stale", + publicError { + staleSelection.createAndSelectWallet("stale") + }, + ) + + enqueueJson("""{"verifier":"in-flight-verifier","loginHint":"user@example.com","challenge":"challenge"}""") + enqueueJson(completeAuthResponseBody(emptyList())) + server.enqueue( + MockResponse + .Builder() + .code(200) + .body("""{"wallet":{"id":"wallet-created","type":"ethereum","address":"0x4444444444444444444444444444444444444444"}}""") + .bodyDelay(300, TimeUnit.MILLISECONDS) + .build(), + ) + val inFlightClient = createOmsClient() + inFlightClient.wallet.startEmailAuth("user@example.com") + val inFlightSelection = + ( + inFlightClient.wallet.completeEmailAuth( + code = "333333", + walletSelection = WalletSelectionBehavior.Manual, + ) as CompleteAuthResult.WalletSelection + ).pendingSelection + val firstSelection = + async { + inFlightSelection.createAndSelectWallet("fresh") + } + yield() + errors += + labeled( + "wallet.pendingWalletSelection.selectWallet.inFlight", + publicError { + inFlightSelection.selectWallet("wallet-created") + }, + ) + errors += + labeled( + "wallet.pendingWalletSelection.createAndSelectWallet.inFlight", + publicError { + inFlightSelection.createAndSelectWallet("duplicate") + }, + ) + firstSelection.await() + + assertEquals( + listOf( + labeled( + "wallet.pendingWalletSelection.selectWallet.unavailable", + error( + name = "OmsWalletSelectionException", + code = "WalletSelectionUnavailable", + operation = "wallet.pendingWalletSelection.selectWallet", + message = "Selected wallet is not one of the available options", + ), + ), + labeled( + "wallet.pendingWalletSelection.selectWallet.stale", + error( + name = "OmsWalletSelectionException", + code = "WalletSelectionStale", + operation = "wallet.pendingWalletSelection.selectWallet", + message = "Pending wallet selection is no longer active", + ), + ), + labeled( + "wallet.pendingWalletSelection.createAndSelectWallet.stale", + error( + name = "OmsWalletSelectionException", + code = "WalletSelectionStale", + operation = "wallet.pendingWalletSelection.createAndSelectWallet", + message = "Pending wallet selection is no longer active", + ), + ), + labeled( + "wallet.pendingWalletSelection.selectWallet.inFlight", + error( + name = "OmsWalletSelectionException", + code = "WalletSelectionInFlight", + operation = "wallet.pendingWalletSelection.selectWallet", + message = "Pending wallet selection already has an action in flight", + ), + ), + labeled( + "wallet.pendingWalletSelection.createAndSelectWallet.inFlight", + error( + name = "OmsWalletSelectionException", + code = "WalletSelectionInFlight", + operation = "wallet.pendingWalletSelection.createAndSelectWallet", + message = "Pending wallet selection already has an action in flight", + ), + ), + ), + errors, + ) + } + + @Test + fun snapshotsMissingSessionContractsForProtectedWalletMethods() = + runBlocking { + val client = createOmsClient() + + assertEquals( + listOf( + missingSession("wallet.listWallets"), + missingSession("wallet.useWallet"), + missingSession("wallet.createWallet"), + missingSession("wallet.getIdToken"), + missingSession("wallet.signMessage"), + missingSession("wallet.signTypedData"), + missingSession("wallet.sendTransaction"), + missingSession("wallet.callContract"), + missingSession("wallet.getTransactionStatus"), + missingSession("wallet.listAccess"), + missingSession("wallet.listAccessPages"), + missingSession("wallet.revokeAccess"), + ), + publicErrors( + "wallet.listWallets" to { + client.wallet.listWallets() + }, + "wallet.useWallet" to { + client.wallet.useWallet("wallet-1") + }, + "wallet.createWallet" to { + client.wallet.createWallet() + }, + "wallet.getIdToken" to { + client.wallet.getIdToken() + }, + "wallet.signMessage" to { + client.wallet.signMessage(Network.POLYGON, "hello") + }, + "wallet.signTypedData" to { + client.wallet.signTypedData( + network = Network.POLYGON, + typedData = + buildJsonObject { + put("contents", "hello") + }, + ) + }, + "wallet.sendTransaction" to { + client.wallet.sendTransaction( + network = Network.POLYGON, + to = "0x1111111111111111111111111111111111111111", + value = BigInteger.ZERO, + ) + }, + "wallet.callContract" to { + client.wallet.callContract( + network = Network.POLYGON, + contract = "0x2222222222222222222222222222222222222222", + method = "transfer(address,uint256)", + args = + listOf( + AbiArg("address", JsonPrimitive("0x3333333333333333333333333333333333333333")), + AbiArg("uint256", JsonPrimitive("1")), + ), + ) + }, + "wallet.getTransactionStatus" to { + client.wallet.getTransactionStatus("txn-1") + }, + "wallet.listAccess" to { + client.wallet.listAccess() + }, + "wallet.listAccessPages" to { + client.wallet.listAccessPages().toList() + }, + "wallet.revokeAccess" to { + client.wallet.revokeAccess("credential-1") + }, + ), + ) + } + + @Test + fun snapshotsOidcLocalErrorContractsWithoutUpstreamDetails() = + runBlocking { + val missingStoreClient = createOmsClient(oidcRedirectAuthStore = null) + val providerErrorClient = createOmsClient() + val invalidLifetimeClient = createOmsClient() + val signerMismatchSigner = MutableCredentialSigner() + val signerMismatchClient = createOmsClient(credentialSigner = signerMismatchSigner) + + enqueueJson("""{"verifier":"verifier-oidc","loginHint":"user@example.com","challenge":"pkce-challenge"}""") + val providerErrorStart = + providerErrorClient.wallet.startOidcRedirectAuth( + provider = testOidcProvider(), + redirectUri = "omsclientkotlindemo://auth/callback", + ) + val providerFailure = + providerErrorClient.wallet.handleOidcRedirectCallback( + callbackUrl = + "omsclientkotlindemo://auth/callback" + + "?error=access_denied&error_description=User%20cancelled&state=${providerErrorStart.state}", + ) + + enqueueJson("""{"verifier":"verifier-oidc","loginHint":"user@example.com","challenge":"pkce-challenge"}""") + val invalidLifetimeStart = + invalidLifetimeClient.wallet.startOidcRedirectAuth( + provider = testOidcProvider(), + redirectUri = "omsclientkotlindemo://auth/callback", + ) + val invalidLifetimeFailure = + invalidLifetimeClient.wallet.handleOidcRedirectCallback( + callbackUrl = "omsclientkotlindemo://auth/callback?code=auth-code&state=${invalidLifetimeStart.state}", + sessionLifetimeSeconds = 0L, + ) + + enqueueJson("""{"verifier":"verifier-oidc","loginHint":"user@example.com","challenge":"pkce-challenge"}""") + val signerMismatchStart = + signerMismatchClient.wallet.startOidcRedirectAuth( + provider = testOidcProvider(), + redirectUri = "omsclientkotlindemo://auth/callback", + ) + signerMismatchSigner.credentialIdValue = "0x04" + "99".repeat(64) + val signerMismatchFailure = + signerMismatchClient.wallet.handleOidcRedirectCallback( + callbackUrl = + "omsclientkotlindemo://auth/callback" + + "?code=auth-code&state=${signerMismatchStart.state}", + ) + + assertEquals( + listOf( + labeled( + "wallet.startOidcRedirectAuth.missingRedirectStorage", + error( + name = "OmsValidationException", + code = "ValidationError", + operation = "wallet.startOidcRedirectAuth", + message = "OIDC redirect auth requires an OIDC redirect auth store", + ), + ), + labeled( + "wallet.handleOidcRedirectCallback.providerError", + error( + name = "OmsSessionException", + code = "SessionMissing", + operation = "wallet.handleOidcRedirectCallback", + message = "User cancelled", + ), + ), + labeled( + "wallet.handleOidcRedirectCallback.invalidLifetime", + error( + name = "OmsValidationException", + code = "ValidationError", + operation = "wallet.handleOidcRedirectCallback", + message = "sessionLifetimeSeconds must be a positive whole number", + ), + ), + labeled( + "wallet.handleOidcRedirectCallback.signerMismatch", + error( + name = "OmsSessionException", + code = "SessionMissing", + operation = "wallet.handleOidcRedirectCallback", + message = "OIDC redirect auth signer mismatch", + ), + ), + ), + listOf( + labeled( + "wallet.startOidcRedirectAuth.missingRedirectStorage", + publicError { + missingStoreClient.wallet.startOidcRedirectAuth( + provider = testOidcProvider(), + redirectUri = "omsclientkotlindemo://auth/callback", + ) + }, + ), + labeled("wallet.handleOidcRedirectCallback.providerError", oidcFailure(providerFailure)), + labeled("wallet.handleOidcRedirectCallback.invalidLifetime", oidcFailure(invalidLifetimeFailure)), + labeled("wallet.handleOidcRedirectCallback.signerMismatch", oidcFailure(signerMismatchFailure)), + ), + ) + } + + @Test + fun snapshotsSdkLocalErrorsWithoutUpstreamDetails() = + runBlocking { + val client = createOmsClientWithSession() + + assertEquals( + error( + name = "OmsValidationException", + code = "ValidationError", + operation = "wallet.sendTransaction", + message = "Transaction value must be non-negative", + ), + publicError { + client.wallet.sendTransaction( + network = Network.POLYGON, + to = "0x1111111111111111111111111111111111111111", + value = BigInteger.ONE.negate(), + ) + }, + ) + } + + @Test + fun snapshotsSignatureValidationBackendFailuresWithUpstreamDetails() = + runBlocking { + val client = createOmsClientWithSession(okHttpClient = failingOkHttpClient("request failed")) + + assertEquals( + listOf( + labeled( + "wallet.isValidMessageSignature", + error( + name = "OmsRequestException", + code = "RequestFailed", + operation = "wallet.isValidMessageSignature", + message = "WebRPC request failed", + retryable = true, + upstreamError = + upstream( + service = "Waas", + name = "WebrpcRequestFailed", + code = "-1", + message = "WebRPC request failed", + ), + ), + ), + labeled( + "wallet.isValidTypedDataSignature", + error( + name = "OmsRequestException", + code = "RequestFailed", + operation = "wallet.isValidTypedDataSignature", + message = "WebRPC request failed", + retryable = true, + upstreamError = + upstream( + service = "Waas", + name = "WebrpcRequestFailed", + code = "-1", + message = "WebRPC request failed", + ), + ), + ), + ), + publicErrors( + "wallet.isValidMessageSignature" to { + client.wallet.isValidMessageSignature( + network = Network.POLYGON, + message = "hello", + signature = "0xmessage", + ) + }, + "wallet.isValidTypedDataSignature" to { + client.wallet.isValidTypedDataSignature( + network = Network.POLYGON, + typedData = + buildJsonObject { + put("contents", "hello") + }, + signature = "0xtyped", + ) + }, + ), + ) + } + + @Test + fun snapshotsDirectTransactionStatusBackendErrorsWithUpstreamDetails() = + runBlocking { + server.enqueue( + MockResponse + .Builder() + .code(404) + .body("""{"error":"TransactionNotFound","code":7308,"msg":"Transaction not found","status":404}""") + .build(), + ) + + val client = createRestoredWalletClient() + + assertEquals( + error( + name = "OmsRequestException", + code = "RequestFailed", + operation = "wallet.getTransactionStatus", + message = "Transaction not found", + status = 404, + retryable = false, + upstreamError = + upstream( + service = "Waas", + name = "TransactionNotFound", + code = "7308", + message = "Transaction not found", + status = 404, + ), + ), + publicError { + client.getTransactionStatus("txn-missing") + }, + ) + } + + @Test + fun snapshotsTransactionLocalValidationErrorsWithoutUpstreamDetails() = + runBlocking { + enqueueJson(prepareTransactionResponse(txnId = "txn-no-fee-options", feeOptions = "[]", sponsored = false)) + + val client = createRestoredWalletClient() + + assertEquals( + error( + name = "OmsValidationException", + code = "ValidationError", + operation = "wallet.sendTransaction", + message = "No fee options available for unsponsored transaction", + ), + publicError { + client.sendTransaction( + network = Network.POLYGON, + request = + SendTransactionRequest( + to = "0x1111111111111111111111111111111111111111", + value = BigInteger.ZERO, + ), + ) + }, + ) + } + + @Test + fun snapshotsTransactionExecuteFailuresAsUnconfirmedWrites() = + runBlocking { + enqueueJson(prepareTransactionResponse(txnId = "txn-execute", feeOptions = "[]", sponsored = true)) + server.enqueue( + MockResponse + .Builder() + .code(502) + .body("Bad Gateway") + .build(), + ) + + val client = createRestoredWalletClient() + + assertEquals( + error( + name = "OmsTransactionException", + code = "TransactionExecutionUnconfirmed", + operation = "wallet.execute", + message = "Transaction execution failed before status could be confirmed", + status = 502, + retryable = false, + txnId = "txn-execute", + upstreamError = + upstream( + service = "Waas", + name = "WebrpcBadResponse", + code = "-5", + message = "bad response", + status = 502, + ), + ), + publicError { + client.sendTransaction( + network = Network.POLYGON, + request = + SendTransactionRequest( + to = "0x1111111111111111111111111111111111111111", + value = BigInteger.ZERO, + ), + ) + }, + ) + } + + @Test + fun snapshotsTransactionStatusPollingFailuresWithTxnAndUpstreamDetails() = + runBlocking { + enqueueJson(prepareTransactionResponse(txnId = "txn-status", feeOptions = "[]", sponsored = true)) + enqueueJson("""{"status":"pending"}""") + server.enqueue( + MockResponse + .Builder() + .code(404) + .body("""{"error":"TransactionNotFound","code":7308,"msg":"Transaction not found","status":404}""") + .build(), + ) + + val client = createRestoredWalletClient() + + assertEquals( + error( + name = "OmsTransactionException", + code = "TransactionStatusLookupFailed", + operation = "wallet.transactionStatus", + message = "Transaction was submitted, but status polling failed", + status = 404, + retryable = true, + txnId = "txn-status", + upstreamError = + upstream( + service = "Waas", + name = "TransactionNotFound", + code = "7308", + message = "Transaction not found", + status = 404, + ), + ), + publicError { + client.sendTransaction( + network = Network.POLYGON, + request = + SendTransactionRequest( + to = "0x1111111111111111111111111111111111111111", + value = BigInteger.ZERO, + ), + ) + }, + ) + } + + @Test + fun snapshotsTransactionStatusPollingTransportFailuresWithTxnAndUpstreamDetails() = + runBlocking { + enqueueJson(prepareTransactionResponse(txnId = "txn-transport", feeOptions = "[]", sponsored = true)) + enqueueJson("""{"status":"pending"}""") + + val client = createRestoredWalletClient(okHttpClient = failingOnRequestOkHttpClient(3, "request failed")) + + assertEquals( + error( + name = "OmsTransactionException", + code = "TransactionStatusLookupFailed", + operation = "wallet.transactionStatus", + message = "Transaction was submitted, but status polling failed", + retryable = true, + txnId = "txn-transport", + upstreamError = + upstream( + service = "Waas", + name = "WebrpcRequestFailed", + code = "-1", + message = "WebRPC request failed", + ), + ), + publicError { + client.sendTransaction( + network = Network.POLYGON, + request = + SendTransactionRequest( + to = "0x1111111111111111111111111111111111111111", + value = BigInteger.ZERO, + ), + ) + }, + ) + } + + @Test + fun snapshotsAccessBackendErrorsWithUpstreamDetails() = + runBlocking { + repeat(3) { + server.enqueue( + MockResponse + .Builder() + .code(401) + .body("""{"error":"Unauthorized","code":7207,"msg":"Unauthorized","status":401}""") + .build(), + ) + } + + val client = createRestoredWalletClient() + val expected = + listOf( + "wallet.listAccess", + "wallet.listAccessPages", + "wallet.revokeAccess", + ).map { operation -> + labeled( + operation, + error( + name = "OmsRequestException", + code = "RequestFailed", + operation = operation, + message = "Unauthorized", + status = 401, + retryable = false, + upstreamError = + upstream( + service = "Waas", + name = "Unauthorized", + code = "7207", + message = "Unauthorized", + status = 401, + ), + ), + ) + } + + assertEquals( + expected, + publicErrors( + "wallet.listAccess" to { + client.listAccess() + }, + "wallet.listAccessPages" to { + client.listAccessPages().toList() + }, + "wallet.revokeAccess" to { + client.revokeAccess("credential-1") + }, + ), + ) + } + + @Test + fun snapshotsIndexerBackendErrorsWithUpstreamDetails() = + runBlocking { + server.enqueue( + MockResponse + .Builder() + .code(503) + .body("""{"error":"Unavailable","code":"INDEXER_UNAVAILABLE","message":"Indexer is unavailable"}""") + .build(), + ) + + val client = createIndexerClient() + + assertEquals( + error( + name = "OmsRequestException", + code = "HttpError", + operation = "indexer.getTokenBalances", + message = "Indexer is unavailable", + status = 503, + retryable = true, + upstreamError = + upstream( + service = "Indexer", + name = "Unavailable", + code = "INDEXER_UNAVAILABLE", + message = "Indexer is unavailable", + status = 503, + ), + ), + publicError { + client.getTokenBalances( + network = Network.POLYGON, + walletAddress = "0x9999999999999999999999999999999999999999", + includeMetadata = false, + ) + }, + ) + } + + @Test + fun snapshotsIndexerNonJsonHttpErrorsWithoutRawUpstreamBodies() = + runBlocking { + server.enqueue( + MockResponse + .Builder() + .code(502) + .body("Bad Gateway") + .build(), + ) + + val client = createIndexerClient() + val failure = + publicError { + client.getTokenBalances( + network = Network.POLYGON, + walletAddress = "0x9999999999999999999999999999999999999999", + includeMetadata = false, + ) + } + + assertEquals( + error( + name = "OmsRequestException", + code = "HttpError", + operation = "indexer.getTokenBalances", + message = "indexer.getTokenBalances failed with HTTP 502", + status = 502, + retryable = true, + upstreamError = + upstream( + service = "Indexer", + message = "indexer.getTokenBalances failed with HTTP 502", + status = 502, + ), + ), + failure, + ) + assertTrue(requireNotNull(failure.message).contains("Bad Gateway").not()) + assertTrue(requireNotNull(failure.upstreamError?.message).contains("Bad Gateway").not()) + } + + @Test + fun snapshotsNativeBalanceIndexerErrorsWithUpstreamDetails() = + runBlocking { + server.enqueue( + MockResponse + .Builder() + .code(503) + .body("""{"error":"Unavailable","code":"INDEXER_UNAVAILABLE","message":"Indexer is unavailable"}""") + .build(), + ) + server.enqueue( + MockResponse + .Builder() + .code(200) + .body("not-json") + .build(), + ) + + val client = createIndexerClient() + val transportClient = + createIndexerClient( + transport = OMSClientHttpClient(failingOkHttpClient("fetch failed")), + ) + + assertEquals( + listOf( + labeled( + "indexer.getNativeTokenBalance.http", + error( + name = "OmsRequestException", + code = "HttpError", + operation = "indexer.getNativeTokenBalance", + message = "Indexer is unavailable", + status = 503, + retryable = true, + upstreamError = + upstream( + service = "Indexer", + name = "Unavailable", + code = "INDEXER_UNAVAILABLE", + message = "Indexer is unavailable", + status = 503, + ), + ), + ), + labeled( + "indexer.getNativeTokenBalance.transport", + error( + name = "OmsRequestException", + code = "RequestFailed", + operation = "indexer.getNativeTokenBalance", + message = "fetch failed", + retryable = true, + upstreamError = + upstream( + service = "Indexer", + name = "IOException", + message = "fetch failed", + ), + ), + ), + labeled( + "indexer.getNativeTokenBalance.malformed", + error( + name = "OmsResponseException", + code = "InvalidResponse", + operation = "indexer.getNativeTokenBalance", + message = "Invalid JSON response from indexer.getNativeTokenBalance", + status = 200, + upstreamError = + upstream( + service = "Indexer", + message = "Invalid JSON response from indexer.getNativeTokenBalance", + status = 200, + ), + ), + ), + ), + listOf( + labeled( + "indexer.getNativeTokenBalance.http", + publicError { + client.getNativeTokenBalance( + network = Network.POLYGON, + walletAddress = "0x9999999999999999999999999999999999999999", + ) + }, + ), + labeled( + "indexer.getNativeTokenBalance.transport", + publicError { + transportClient.getNativeTokenBalance( + network = Network.POLYGON, + walletAddress = "0x9999999999999999999999999999999999999999", + ) + }, + ), + labeled( + "indexer.getNativeTokenBalance.malformed", + publicError { + client.getNativeTokenBalance( + network = Network.POLYGON, + walletAddress = "0x9999999999999999999999999999999999999999", + ) + }, + ), + ), + ) + } + + @Test + fun snapshotsIndexerTransportFailuresWithUpstreamDetails() = + runBlocking { + val client = + createIndexerClient( + transport = OMSClientHttpClient(failingOkHttpClient("fetch failed")), + ) + + assertEquals( + error( + name = "OmsRequestException", + code = "RequestFailed", + operation = "indexer.getTokenBalances", + message = "fetch failed", + retryable = true, + upstreamError = + upstream( + service = "Indexer", + name = "IOException", + message = "fetch failed", + ), + ), + publicError { + client.getTokenBalances( + network = Network.POLYGON, + walletAddress = "0x9999999999999999999999999999999999999999", + includeMetadata = false, + ) + }, + ) + } + + @Test + fun snapshotsIndexerMalformedResponseErrorsWithUpstreamDetails() = + runBlocking { + server.enqueue( + MockResponse + .Builder() + .code(200) + .body("not-json") + .build(), + ) + + val client = createIndexerClient() + + assertEquals( + error( + name = "OmsResponseException", + code = "InvalidResponse", + operation = "indexer.getTokenBalances", + message = "Invalid JSON response from indexer.getTokenBalances", + status = 200, + upstreamError = + upstream( + service = "Indexer", + message = "Invalid JSON response from indexer.getTokenBalances", + status = 200, + ), + ), + publicError { + client.getTokenBalances( + network = Network.POLYGON, + walletAddress = "0x9999999999999999999999999999999999999999", + includeMetadata = false, + ) + }, + ) + } + + @Test + fun snapshotsExportedErrorHelperAndSubclassFields() { + val upstreamError = + OmsUpstreamError( + service = OmsUpstreamService.Waas, + name = "WebrpcBadResponse", + code = "-5", + message = "bad response", + status = 502, + ) + + val error = + OmsRequestException( + code = OmsSdkErrorCode.HttpError, + operation = OmsSdkOperation.WalletStartEmailAuth, + status = 502, + retryable = true, + upstreamError = upstreamError, + message = "bad gateway", + ) + + assertEquals( + error( + name = "OmsRequestException", + code = "HttpError", + operation = "wallet.startEmailAuth", + message = "bad gateway", + status = 502, + retryable = true, + upstreamError = + upstream( + service = "Waas", + name = "WebrpcBadResponse", + code = "-5", + message = "bad response", + status = 502, + ), + ), + error.serializePublicFields(), + ) + } + + private fun createOmsClient( + okHttpClient: OkHttpClient = OkHttpClient(), + oidcRedirectAuthStore: InMemoryOidcRedirectAuthStore? = InMemoryOidcRedirectAuthStore(), + credentialSigner: CredentialSigner = TrackingCredentialSigner(), + ): OMSClient = + OMSClient( + publishableKey = "test-publishable-key", + projectId = "test-project-id", + environment = testEnvironment(), + okHttpClient = okHttpClient, + sessionStore = InMemorySessionStore(), + oidcRedirectAuthStore = oidcRedirectAuthStore, + credentialSigner = credentialSigner, + ) + + private fun createOmsClientWithSession(okHttpClient: OkHttpClient = OkHttpClient()): OMSClient = + createOmsClient(okHttpClient = okHttpClient).also { client -> + client.wallet.restoreSession(activeSessionSnapshot()) + } + + private fun createRestoredWalletClient(okHttpClient: OkHttpClient = OkHttpClient()): WalletClient { + val client = + WalletClient( + publishableKey = "test-publishable-key", + projectId = "test-project-id", + environment = testEnvironment(), + transport = OMSClientHttpClient(okHttpClient), + sessionStore = + InMemorySessionStore( + OMSClientSessionSnapshot( + walletId = "wallet-main", + walletAddress = "0x9999999999999999999999999999999999999999", + signerAddress = TEST_CREDENTIAL_ID, + signerKeyType = WalletSigningAlgorithm.ECDSA_P256_SHA256, + ), + ), + credentialSigner = TrackingCredentialSigner(), + transactionStatusDelay = {}, + ) + assertTrue(client.restorePersistedSession()) + return client + } + + private fun createIndexerClient(transport: OMSClientHttpClient = OMSClientHttpClient()): IndexerClient = + IndexerClient( + publishableKey = "test-publishable-key", + environment = testEnvironment(), + transport = transport, + ) + + private fun testEnvironment(): OMSClientEnvironment = + OMSClientEnvironment( + walletApiUrl = server.url("/rpc/Wallet/").toString(), + indexerUrlTemplate = server.url("/indexer/polygon/rpc/Indexer/").toString().replace("/polygon/", "/{value}/"), + ) + + private fun testOidcProvider(): OidcProviderConfig = + OidcProviderConfig( + issuer = "https://issuer.example", + clientId = "client-id", + authorizationUrl = "https://issuer.example/oauth/authorize", + ) + + private fun failingOkHttpClient(message: String): OkHttpClient = + OkHttpClient + .Builder() + .addInterceptor( + Interceptor { + throw IOException(message) + }, + ).build() + + private fun failingOnRequestOkHttpClient( + requestNumber: Int, + message: String, + ): OkHttpClient { + val requestCount = AtomicInteger() + return OkHttpClient + .Builder() + .addInterceptor( + Interceptor { chain -> + if (requestCount.incrementAndGet() == requestNumber) { + throw IOException(message) + } + chain.proceed(chain.request()) + }, + ).build() + } + + private fun enqueueJson(body: String) { + server.enqueue( + MockResponse + .Builder() + .code(200) + .body(body) + .build(), + ) + } + + private fun prepareTransactionResponse( + txnId: String, + feeOptions: String, + sponsored: Boolean, + ): String = + """ + { + "txnId": "$txnId", + "status": "quoted", + "feeOptions": $feeOptions, + "sponsored": $sponsored, + "expiresAt": "2099-01-01T00:00:00Z" + } + """.trimIndent() + + private suspend fun publicErrors(vararg cases: Pair Any?>): List = + cases.map { (label, action) -> + labeled(label, publicError(action)) + } + + private suspend fun publicError(action: suspend () -> Any?): SerializedError { + try { + action() + } catch (throwable: Throwable) { + return throwable.serializePublicFields() + } + + error("Expected public API call to fail") + } + + private fun oidcFailure(result: OidcRedirectAuthResult): SerializedError = + when (result) { + is OidcRedirectAuthResult.Failed -> result.error.serializePublicFields() + else -> error("Expected OIDC redirect result to fail, got $result") + } + + private fun missingSession( + operation: String, + message: String = "No active wallet session", + ): LabeledError = + labeled( + operation, + error( + name = "OmsSessionException", + code = "SessionMissing", + operation = operation, + message = message, + ), + ) + + private fun Throwable.serializePublicFields(): SerializedError { + val sdkError = this as? OmsSdkException + return SerializedError( + name = javaClass.simpleName, + code = sdkError?.code?.name, + operation = sdkError?.operation?.id, + message = message, + status = sdkError?.status, + retryable = sdkError?.retryable, + txnId = sdkError?.txnId, + upstreamError = sdkError?.upstreamError?.serializePublicFields(), + ) + } + + private fun OmsUpstreamError.serializePublicFields(): SerializedUpstreamError = + SerializedUpstreamError( + service = service.name, + name = name, + code = code, + message = message, + status = status, + ) + + private fun labeled( + label: String, + error: SerializedError, + ): LabeledError = LabeledError(label = label, error = error) + + private fun error( + name: String, + code: String?, + operation: String?, + message: String?, + status: Int? = null, + retryable: Boolean? = null, + txnId: String? = null, + upstreamError: SerializedUpstreamError? = null, + ): SerializedError = + SerializedError( + name = name, + code = code, + operation = operation, + message = message, + status = status, + retryable = retryable, + txnId = txnId, + upstreamError = upstreamError, + ) + + private fun upstream( + service: String, + name: String? = null, + code: String? = null, + message: String? = null, + status: Int? = null, + ): SerializedUpstreamError = + SerializedUpstreamError( + service = service, + name = name, + code = code, + message = message, + status = status, + ) + + private data class LabeledError( + val label: String, + val error: SerializedError, + ) + + private class MutableCredentialSigner( + var credentialIdValue: String = TEST_CREDENTIAL_ID, + ) : CredentialSigner { + override val signingAlgorithm: WalletSigningAlgorithm = WalletSigningAlgorithm.ECDSA_P256_SHA256 + + override suspend fun credentialId(): String = credentialIdValue + + override suspend fun nextNonce(): String = "1710000999" + + override suspend fun sign(preimage: String): String = "0x" + "22".repeat(64) + + override fun hasCredential(): Boolean = true + + override fun clear() = Unit + } + + private data class SerializedError( + val name: String?, + val code: String?, + val operation: String?, + val message: String?, + val status: Int?, + val retryable: Boolean?, + val txnId: String?, + val upstreamError: SerializedUpstreamError?, + ) + + private data class SerializedUpstreamError( + val service: String?, + val name: String?, + val code: String?, + val message: String?, + val status: Int?, + ) +} From 0c4c455102c87be36b9aace2aebf7d2d45da33e4 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 12 Jun 2026 16:25:02 +0300 Subject: [PATCH 3/4] Clarify error contract maintenance guidance --- AGENTS.md | 10 ++++++++++ TESTING.md | 10 ++++++++++ docs/error-contracts.md | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3ead426..e2b6f20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,10 +228,20 @@ and the execution command reference. `PublicErrorContractsTest`; focused tests should cover behavior or edge cases without duplicating the full matrix. - Exercise real public runtime APIs and mock only external boundaries. +- Do not assert manually constructed `OmsSdkException` subclasses unless the + error class or helper is the unit under test. - Assert stable public fields only; do not assert raw `cause`, stacks, generated internals, headers, timestamps, or full backend payloads as public contract. +- Keep backend and upstream mapping tests representative rather than exhaustive + per method; cover each transport family through real public calls. - Include `upstreamError` only when the path crosses a remote service response or transport boundary. SDK-local failures should not expose upstream details. +- Android storage and Keystore signer internals belong in focused platform + tests unless they are intentionally normalized through documented public SDK + errors. +- Serialized contract changes are not automatically regressions. First decide + whether the new error shape is intended, then update the assertion and related + docs or fix the implementation. ## Generated Files and External Artifacts diff --git a/TESTING.md b/TESTING.md index a0fe4f4..a0fe505 100644 --- a/TESTING.md +++ b/TESTING.md @@ -53,15 +53,25 @@ Key subdirectories: duplicating the full public-field matrix. - Exercise real public runtime APIs such as `client.wallet.*`, `client.indexer.*`, auth result actions, and public exception classes. +- Do not assert manually constructed `OmsSdkException` subclasses unless the error class or helper + is the unit under test. - Mock only external boundaries: network responses, time, randomness, Android platform services, or signer behavior. - Assert stable public fields only: exception class, `code`, `operation`, `message`, `status`, `retryable`, `txnId`, and `upstreamError`. - Do not assert raw `cause`, stacks, generated WebRPC internals, request headers, timestamps, or full backend payloads as public error contract fields. +- Keep backend and upstream mapping tests representative rather than exhaustive per method; cover + each transport family through real public calls. - Include `upstreamError` only when the tested path truthfully crosses a remote service or transport boundary. SDK-local validation, session, and wallet-selection failures should assert no upstream details. +- Android storage and Keystore signer classes are internal platform boundaries, not separate public + SDK error surfaces. Cover their failures in focused JVM or instrumented tests unless a failure is + intentionally normalized through a documented public `OmsSdkException`. +- Serialized contract changes are not automatically regressions. Decide whether the new error shape + is the intended public contract: if correct, update the assertion and related docs; if accidental, + fix the implementation. Never update expectations blindly. - Treat `code` and `operation` as stronger contract fields than `message`. Message changes are allowed when intentional, but they should be reviewed as user-visible API/UX changes. - `retryable` describes the failed SDK operation, not the whole user intent. A retryable status diff --git a/docs/error-contracts.md b/docs/error-contracts.md index f380291..0d734bf 100644 --- a/docs/error-contracts.md +++ b/docs/error-contracts.md @@ -18,6 +18,24 @@ whether `upstreamError` should be present, and which tests own the contract. - `TransactionStatusLookupFailed` means the transaction was submitted, but post-submit status polling failed. Retry by checking transaction status with the returned `txnId`. +## Maintenance Approach + +- Update this matrix, the centralized `PublicErrorContractsTest`, and public docs together when a + public SDK method gains, removes, or intentionally changes an error contract. +- Keep backend and upstream mapping tests representative rather than exhaustive per method. Cover + each transport or response family through real public calls instead of duplicating the same + matrix across focused tests. +- Do not assert manually constructed `OmsSdkException` subclasses unless the error class or helper + is the unit under test. Public runtime APIs should own runtime error contract coverage. +- Android storage and Keystore signer classes are internal platform boundaries in this SDK, not + separate public error surfaces. Cover their failures in focused platform tests unless a failure is + intentionally normalized through a documented public `OmsSdkException`. +- Serialized contract changes are not automatically regressions. Decide whether the new error shape + is the intended public contract: if correct, update the assertion and related docs; if accidental, + fix the implementation. Never update expectations blindly. +- Use `code` and `operation` as the primary compatibility contract. Treat message changes as + intentional user-visible API/UX changes, even when they do not change recovery behavior. + ## SDK Matrix | Public surface | Failure family | User-facing error | Recovery meaning | `upstreamError` | Covering test | From 29476e1569c6a7e60f175565d7ddb01cea822f75 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Fri, 12 Jun 2026 16:29:57 +0300 Subject: [PATCH 4/4] Keep listAccessPages emits outside SDK error wrapper --- .../omsclient/kotlin_sdk/wallet/WalletClient.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt index 51cc448..ef45dc6 100644 --- a/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt +++ b/oms-client-kotlin-sdk/src/main/java/com/omsclient/kotlin_sdk/wallet/WalletClient.kt @@ -966,18 +966,18 @@ class WalletClient internal constructor( */ fun listAccessPages(pageSize: UInt? = null): Flow = flow { - runOmsOperation(OmsSdkOperation.WalletListAccessPages) { - var cursor: String? = null - do { - val response = + var cursor: String? = null + do { + val response = + runOmsOperation(OmsSdkOperation.WalletListAccessPages) { requestListAccessPage( pageSize = pageSize, cursor = cursor, ) - emit(response) - cursor = response.page?.cursor?.takeIf { it.isNotBlank() } - } while (cursor != null) - } + } + emit(response) + cursor = response.page?.cursor?.takeIf { it.isNotBlank() } + } while (cursor != null) } /**