diff --git a/stripe-attestation/src/main/java/com/stripe/attestation/AttestationError.kt b/stripe-attestation/src/main/java/com/stripe/attestation/AttestationError.kt new file mode 100644 index 00000000000..e34cb4b8ae5 --- /dev/null +++ b/stripe-attestation/src/main/java/com/stripe/attestation/AttestationError.kt @@ -0,0 +1,72 @@ +package com.stripe.attestation + +import androidx.annotation.RestrictTo +import com.google.android.play.core.integrity.StandardIntegrityException +import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class AttestationError( + val errorType: ErrorType, + message: String, + cause: Throwable? = null +) : Exception(message, cause) { + + enum class ErrorType( + val isRetriable: Boolean + ) { + API_NOT_AVAILABLE(isRetriable = false), + APP_NOT_INSTALLED(isRetriable = false), + APP_UID_MISMATCH(isRetriable = false), + CANNOT_BIND_TO_SERVICE(isRetriable = true), + CLIENT_TRANSIENT_ERROR(isRetriable = true), + CLOUD_PROJECT_NUMBER_IS_INVALID(isRetriable = false), + GOOGLE_SERVER_UNAVAILABLE(isRetriable = true), + INTEGRITY_TOKEN_PROVIDER_INVALID(isRetriable = false), + INTERNAL_ERROR(isRetriable = true), + NO_ERROR(isRetriable = true), + NETWORK_ERROR(isRetriable = true), + PLAY_SERVICES_NOT_FOUND(isRetriable = false), + PLAY_SERVICES_VERSION_OUTDATED(isRetriable = false), + PLAY_STORE_NOT_FOUND(isRetriable = true), + PLAY_STORE_VERSION_OUTDATED(isRetriable = false), + REQUEST_HASH_TOO_LONG(isRetriable = false), + TOO_MANY_REQUESTS(isRetriable = true), + MAX_RETRIES_EXCEEDED(isRetriable = false), + UNKNOWN(isRetriable = false) + } + + companion object { + fun fromException(exception: Throwable): AttestationError = when (exception) { + is StandardIntegrityException -> AttestationError( + errorType = errorCodeToErrorTypeMap[exception.errorCode] ?: ErrorType.UNKNOWN, + message = exception.message ?: "Integrity error occurred", + cause = exception + ) + else -> AttestationError( + errorType = ErrorType.UNKNOWN, + message = "An unknown error occurred", + cause = exception + ) + } + + private val errorCodeToErrorTypeMap = mapOf( + StandardIntegrityErrorCode.API_NOT_AVAILABLE to ErrorType.API_NOT_AVAILABLE, + StandardIntegrityErrorCode.APP_NOT_INSTALLED to ErrorType.APP_NOT_INSTALLED, + StandardIntegrityErrorCode.APP_UID_MISMATCH to ErrorType.APP_UID_MISMATCH, + StandardIntegrityErrorCode.CANNOT_BIND_TO_SERVICE to ErrorType.CANNOT_BIND_TO_SERVICE, + StandardIntegrityErrorCode.CLIENT_TRANSIENT_ERROR to ErrorType.CLIENT_TRANSIENT_ERROR, + StandardIntegrityErrorCode.CLOUD_PROJECT_NUMBER_IS_INVALID to ErrorType.CLOUD_PROJECT_NUMBER_IS_INVALID, + StandardIntegrityErrorCode.GOOGLE_SERVER_UNAVAILABLE to ErrorType.GOOGLE_SERVER_UNAVAILABLE, + StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID to ErrorType.INTEGRITY_TOKEN_PROVIDER_INVALID, + StandardIntegrityErrorCode.INTERNAL_ERROR to ErrorType.INTERNAL_ERROR, + StandardIntegrityErrorCode.NETWORK_ERROR to ErrorType.NETWORK_ERROR, + StandardIntegrityErrorCode.NO_ERROR to ErrorType.NO_ERROR, + StandardIntegrityErrorCode.PLAY_SERVICES_NOT_FOUND to ErrorType.PLAY_SERVICES_NOT_FOUND, + StandardIntegrityErrorCode.PLAY_SERVICES_VERSION_OUTDATED to ErrorType.PLAY_SERVICES_VERSION_OUTDATED, + StandardIntegrityErrorCode.PLAY_STORE_NOT_FOUND to ErrorType.PLAY_STORE_NOT_FOUND, + StandardIntegrityErrorCode.PLAY_STORE_VERSION_OUTDATED to ErrorType.PLAY_STORE_VERSION_OUTDATED, + StandardIntegrityErrorCode.REQUEST_HASH_TOO_LONG to ErrorType.REQUEST_HASH_TOO_LONG, + StandardIntegrityErrorCode.TOO_MANY_REQUESTS to ErrorType.TOO_MANY_REQUESTS + ) + } +} diff --git a/stripe-attestation/src/main/java/com/stripe/attestation/IntegrityStandardRequestManager.kt b/stripe-attestation/src/main/java/com/stripe/attestation/IntegrityStandardRequestManager.kt index 415eb4b8952..007a79a84dd 100644 --- a/stripe-attestation/src/main/java/com/stripe/attestation/IntegrityStandardRequestManager.kt +++ b/stripe-attestation/src/main/java/com/stripe/attestation/IntegrityStandardRequestManager.kt @@ -1,11 +1,11 @@ package com.stripe.attestation import androidx.annotation.RestrictTo -import com.google.android.gms.tasks.Task import com.google.android.play.core.integrity.StandardIntegrityManager import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest +import kotlinx.coroutines.delay @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) interface IntegrityRequestManager { @@ -13,9 +13,15 @@ interface IntegrityRequestManager { * Prepare the integrity token. This warms up the integrity token generation, it's recommended * to call it as soon as possible if you know you will need an integrity token. * + * @param maxRetries The number of times to retry the request (using exponential backoff). + * Increase this value if calls to this method are non-blocking. + * See https://developer.android.com/google/play/integrity/error-codes#retry-logic + * * Needs to be called before calling [requestToken]. */ - suspend fun prepare(): Result + suspend fun prepare( + maxRetries: Int = 0, + ): Result /** * Requests an Integrity token. @@ -23,10 +29,16 @@ interface IntegrityRequestManager { * @param requestIdentifier A string to be hashed to generate a request identifier. * Can be null. Provide a value that identifies the API request * to protect it from tampering attacks. + * @param maxRetries The number of times to retry the request (using exponential backoff). + * Increase this value if calls to this method are non-blocking. + * See https://developer.android.com/google/play/integrity/error-codes#retry-logic * * [Docs](https://developer.android.com/google/play/integrity/standard#protect-requests) */ - suspend fun requestToken(requestIdentifier: String? = null): Result + suspend fun requestToken( + requestIdentifier: String? = null, + maxRetries: Int = 0, + ): Result } @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @@ -39,39 +51,95 @@ class IntegrityStandardRequestManager( private val standardIntegrityManager: StandardIntegrityManager by lazy { factory.create() } private var integrityTokenProvider: StandardIntegrityTokenProvider? = null - override suspend fun prepare(): Result = runCatching { - val finishedTask: Task = standardIntegrityManager - .prepareIntegrityToken( - PrepareIntegrityTokenRequest.builder() - .setCloudProjectNumber(cloudProjectNumber) - .build() - ).awaitTask() + override suspend fun prepare( + retries: Int, + ): Result = exponentialBackoff(maxRetries = retries) { + runCatching { + val finishedTask = standardIntegrityManager + .prepareIntegrityToken( + PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(cloudProjectNumber) + .build() + ).awaitTask() - finishedTask.toResult() - .onSuccess { integrityTokenProvider = it } - .onFailure { error -> logError("Integrity: Failed to prepare integrity token", error) } - .getOrThrow() + finishedTask.toResult() + .onSuccess { integrityTokenProvider = it } + .getOrThrow() + } + .map {} + .recoverCatching { + logError("Integrity - Failed to prepare integrity token", it) + throw AttestationError.fromException(it) + } } override suspend fun requestToken( requestIdentifier: String?, - ): Result = request(requestIdentifier) + maxRetries: Int, + ): Result = request(requestIdentifier, maxRetries) private suspend fun request( requestHash: String?, - ): Result = runCatching { - val finishedTask = requireNotNull( - value = integrityTokenProvider, - lazyMessage = { "Integrity token provider is not initialized. Call prepare() first." } - ).request( - StandardIntegrityTokenRequest.builder() - .setRequestHash(requestHash) - .build() - ).awaitTask() + maxRetries: Int, + ): Result = exponentialBackoff( + maxRetries = maxRetries, + ) { + runCatching { + val finishedTask = requireNotNull( + value = integrityTokenProvider, + lazyMessage = { "Integrity token provider is not initialized. Call prepare() first." } + ).request( + StandardIntegrityTokenRequest.builder() + .setRequestHash(requestHash) + .build() + ).awaitTask() + + finishedTask.toResult().getOrThrow() + } + .map { it.token() } + .recoverCatching { + logError("Integrity - Failed to prepare integrity token", it) + throw AttestationError.fromException(it) + } + } + + suspend fun exponentialBackoff( + maxRetries: Int, + initialDelay: Long = INITIAL_DELAY, + block: suspend () -> Result + ): Result { + var currentDelay = initialDelay + val totalTries = maxRetries + 1 + repeat(totalTries) { attempt -> + val result = block() + + if (result.isSuccess) { + return result + } + + val exception = result.exceptionOrNull() + + // Retry only if the error is retriable + if (exception is AttestationError && exception.errorType.isRetriable) { + logError("Retrying due to retriable error on attempt $attempt", exception) + delay(currentDelay) + currentDelay = (currentDelay * MULTIPLIER).toLong() + } else { + return result + } + } + return Result.failure( + AttestationError( + errorType = AttestationError.ErrorType.MAX_RETRIES_EXCEEDED, + message = "Failed after $maxRetries attempts, giving up.", + cause = null + ) + ) + } - finishedTask.toResult() - .mapCatching { it.token() } - .onFailure { error -> logError("Integrity - Failed to request integrity token", error) } - .getOrThrow() + companion object { + // Constants for the retry mechanism + private const val INITIAL_DELAY = 2000L // Start with 2 seconds + private const val MULTIPLIER = 2.0 } } diff --git a/stripe-attestation/src/test/java/com/stripe/attestation/IntegrityStandardRequestManagerTest.kt b/stripe-attestation/src/test/java/com/stripe/attestation/IntegrityStandardRequestManagerTest.kt index 6bde3f5089c..08439e6dbcb 100644 --- a/stripe-attestation/src/test/java/com/stripe/attestation/IntegrityStandardRequestManagerTest.kt +++ b/stripe-attestation/src/test/java/com/stripe/attestation/IntegrityStandardRequestManagerTest.kt @@ -26,7 +26,7 @@ class IntegrityStandardRequestManagerTest { } @Test - fun `prepare - success`() = runTest { + fun `prepare - success returns successful result`() = runTest { val tokenProvider = FakeStandardIntegrityTokenProvider(Tasks.forResult(FakeStandardIntegrityToken())) val integrityStandardRequestManager = buildRequestManager( prepareTask = Tasks.forResult(tokenProvider), @@ -38,7 +38,7 @@ class IntegrityStandardRequestManagerTest { } @Test - fun `prepare - failure`() = runTest { + fun `prepare - failure on prepare task returns Attestation error`() = runTest { val integrityStandardRequestManager = buildRequestManager( prepareTask = Tasks.forException(Exception("Failed to build token provider")), ) @@ -46,6 +46,7 @@ class IntegrityStandardRequestManagerTest { val result = integrityStandardRequestManager.prepare() assert(result.isFailure) + assert(result.exceptionOrNull() is AttestationError) } @Test @@ -72,6 +73,7 @@ class IntegrityStandardRequestManagerTest { val result = integrityStandardRequestManager.requestToken("requestIdentifier") assert(result.isFailure) + assert(result.exceptionOrNull() is AttestationError) } @After