Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Attestation] Adds retry logic on retriable errors #9813

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,44 @@
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 {
/**
* 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<Unit>
suspend fun prepare(
maxRetries: Int = 0,
): Result<Unit>

/**
* Requests an Integrity token.
*
* @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<String>
suspend fun requestToken(
requestIdentifier: String? = null,
maxRetries: Int = 0,
): Result<String>
}

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
Expand All @@ -39,39 +51,95 @@ class IntegrityStandardRequestManager(
private val standardIntegrityManager: StandardIntegrityManager by lazy { factory.create() }
private var integrityTokenProvider: StandardIntegrityTokenProvider? = null

override suspend fun prepare(): Result<Unit> = runCatching {
val finishedTask: Task<StandardIntegrityTokenProvider> = standardIntegrityManager
.prepareIntegrityToken(
PrepareIntegrityTokenRequest.builder()
.setCloudProjectNumber(cloudProjectNumber)
.build()
).awaitTask()
override suspend fun prepare(
retries: Int,
): Result<Unit> = 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<String> = request(requestIdentifier)
maxRetries: Int,
): Result<String> = request(requestIdentifier, maxRetries)

private suspend fun request(
requestHash: String?,
): Result<String> = 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<String> = 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 <T> exponentialBackoff(
maxRetries: Int,
initialDelay: Long = INITIAL_DELAY,
block: suspend () -> Result<T>
): Result<T> {
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<T>(
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -38,14 +38,15 @@ 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")),
)

val result = integrityStandardRequestManager.prepare()

assert(result.isFailure)
assert(result.exceptionOrNull() is AttestationError)
}

@Test
Expand All @@ -72,6 +73,7 @@ class IntegrityStandardRequestManagerTest {
val result = integrityStandardRequestManager.requestToken("requestIdentifier")

assert(result.isFailure)
assert(result.exceptionOrNull() is AttestationError)
}

@After
Expand Down
Loading