From fc179d554150edd11c5b174cbac6be91bc045c98 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 22 Jan 2026 00:38:07 +0530 Subject: [PATCH] feat: Implement automatic retry mechanism for credential retrieval with exponential backoff --- EXAMPLES.md | 61 +++++ .../authentication/AuthenticationException.kt | 7 + .../storage/CredentialsManager.kt | 71 +++++- .../storage/SecureCredentialsManager.kt | 27 +- .../storage/CredentialsManagerTest.kt | 231 ++++++++++++++++++ 5 files changed, 387 insertions(+), 10 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index d124aa8c2..e21704762 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1415,6 +1415,67 @@ SecureCredentialsManager manager = new SecureCredentialsManager(apiClient, this, ``` +#### Automatic Retry on Credential Retrieval + +Both `CredentialsManager` and `SecureCredentialsManager` support automatic retry with exponential backoff for credential retrieval operations (`getCredentials()` and its variants) that fail due to transient errors. This is particularly useful for handling temporary network issues on mobile devices during credential renewal. + +> **Note:** Automatic retry is only supported for credential retrieval operations (`getCredentials()`, `awaitCredentials()`). It does not apply to other operations such as SSO credentials, API credentials, or explicit renewal calls. + +**Retryable Errors:** +- Network errors (timeouts, connection lost, DNS failures) +- Rate limiting (HTTP 429) +- Server errors (HTTP 5xx) + +**Configuring Retries:** + +The `maxRetries` parameter can be set when creating a `CredentialsManager` or `SecureCredentialsManager`. The default value is 0 (no retries). It is recommended to use a maximum of 2 retries. + +```kotlin +val storage = SharedPreferencesStorage(this) +val manager = CredentialsManager( + authenticationClient = apiClient, + storage = storage, + maxRetries = 2 // Enable up to 2 retry attempts +) +``` + +
+ Using Java + +```java +Storage storage = new SharedPreferencesStorage(this); +CredentialsManager manager = new CredentialsManager(apiClient, storage, 2); +``` +
+ +For `SecureCredentialsManager`: + +```kotlin +val storage = SharedPreferencesStorage(this) +val manager = SecureCredentialsManager( + context = this, + account = account, + storage = storage, + maxRetries = 2 // Enable up to 2 retry attempts +) +``` + +
+ Using Java + +```java +Storage storage = new SharedPreferencesStorage(this); +SecureCredentialsManager manager = new SecureCredentialsManager(this, account, storage, 2); +``` +
+ +**Important Considerations:** + +- **Scope:** Automatic retry only works for credential retrieval operations (`getCredentials()`, `awaitCredentials()` and their overloads). Other operations like getting SSO credentials or API credentials do not support automatic retry. +- **Auth0 Tenant Configuration**: Ensure your Auth0 tenant is configured with a minimum **180-second refresh token rotation overlap period** to safely retry credential renewals without requiring user re-authentication. +- **Exponential Backoff**: The retry mechanism uses exponential backoff with delays of 0.5s, 1s, 2s, etc., between attempts. +- **Non-Retryable Errors**: Errors like invalid refresh tokens or authentication failures will not be retried automatically. + #### Requiring Authentication You can require the user authentication to obtain credentials. This will make the manager prompt the user with the device's configured Lock Screen, which they must pass correctly in order to obtain the credentials. **This feature is only available on devices where the user has setup a secured Lock Screen** (PIN, Pattern, Password or Fingerprint). diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt index b5627c0b0..10bc83e90 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -209,6 +209,13 @@ public class AuthenticationException : Auth0Exception { public val isTooManyAttempts: Boolean get() = "too_many_attempts" == code + /** + * Returns true if this error is retryable with exponential backoff. + * Retryable errors include network errors, rate limiting (429), and server errors (5xx). + */ + public val isRetryable: Boolean + get() = isNetworkError || statusCode == 429 || statusCode in 500..599 + internal companion object { internal const val ERROR_VALUE_AUTHENTICATION_CANCELED = "a0.authentication_canceled" internal const val ERROR_KEY_URI_NULL = "a0.auth.authorize_uri" diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 2be12fc26..5897aa71c 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -29,7 +29,8 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting authenticationClient: AuthenticationAPIClient, storage: Storage, jwtDecoder: JWTDecoder, - private val serialExecutor: Executor + private val serialExecutor: Executor, + private val maxRetries: Int ) : BaseCredentialsManager(authenticationClient, storage, jwtDecoder) { private val gson: Gson = GsonProvider.gson @@ -39,12 +40,18 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * * @param authenticationClient the Auth0 Authentication client to refresh credentials with. * @param storage the storage to use for the credentials. + * @param maxRetries the maximum number of retry attempts for credential renewal when encountering transient errors. Default is 0 (no retries). */ - public constructor(authenticationClient: AuthenticationAPIClient, storage: Storage) : this( + public constructor( + authenticationClient: AuthenticationAPIClient, + storage: Storage, + maxRetries: Int = 0 + ) : this( authenticationClient, storage, JWTDecoder(), - Executors.newSingleThreadExecutor() + Executors.newSingleThreadExecutor(), + maxRetries.coerceAtLeast(0) ) public override val userProfile: UserProfile? @@ -469,6 +476,21 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting headers: Map, forceRefresh: Boolean, callback: Callback + ) { + getCredentialsWithRetry(scope, minTtl, parameters, headers, forceRefresh, 0, callback) + } + + /** + * Internal method that implements retry logic for credential retrieval. + */ + private fun getCredentialsWithRetry( + scope: String?, + minTtl: Int, + parameters: Map, + headers: Map, + forceRefresh: Boolean, + retryCount: Int, + callback: Callback ) { serialExecutor.execute { val accessToken = storage.retrieveString(KEY_ACCESS_TOKEN) @@ -544,6 +566,26 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting saveCredentials(credentials) callback.onSuccess(credentials) } catch (error: AuthenticationException) { + if (shouldRetryRenewal(error, retryCount)) { + val delay = calculateRetryDelay(retryCount) + Log.d( + TAG, + "Retrying credential renewal (attempt ${retryCount + 1}/$maxRetries) after ${delay}ms due to retryable error: ${error.getDescription()}" + ) + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + getCredentialsWithRetry( + scope, + minTtl, + parameters, + headers, + forceRefresh, + retryCount + 1, + callback + ) + }, delay) + return@execute + } + val exception = when { error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED @@ -756,6 +798,29 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting return Credentials(idToken, accessToken, tokenType, refreshToken, expiresAt, scope) } + /** + * Determines if a credential renewal operation should be retried based on the error and current retry count. + * + * @param error the error that occurred during the renewal attempt + * @param retryCount the current number of retry attempts + * @return true if the operation should be retried, false otherwise + */ + private fun shouldRetryRenewal(error: AuthenticationException, retryCount: Int): Boolean { + return retryCount < maxRetries && error.isRetryable + } + + /** + * Calculates the exponential backoff delay for a given retry attempt. + * Formula: 2^retryCount * 0.5 seconds + * + * @param retryCount the current retry attempt number (0-indexed) + * @return the delay in milliseconds + */ + private fun calculateRetryDelay(retryCount: Int): Long { + val delaySeconds = Math.pow(2.0, retryCount.toDouble()) * 0.5 + return (delaySeconds * 1000).toLong() + } + private companion object { private const val KEY_ACCESS_TOKEN = "com.auth0.access_token" private const val KEY_REFRESH_TOKEN = "com.auth0.refresh_token" diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index 70abe7ada..8baf9ab54 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -40,6 +40,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT private val fragmentActivity: WeakReference? = null, private val localAuthenticationOptions: LocalAuthenticationOptions? = null, private val localAuthenticationManagerFactory: LocalAuthenticationManagerFactory? = null, + private val maxRetries: Int = 0 ) : BaseCredentialsManager(apiClient, storage, jwtDecoder) { private val gson: Gson = GsonProvider.gson @@ -52,16 +53,19 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * @param context a valid context * @param auth0 the Auth0 account information to use * @param storage the storage implementation to use + * @param maxRetries the maximum number of retry attempts for credential renewal when encountering transient errors. Default is 0 (no retries). */ public constructor( context: Context, auth0: Auth0, storage: Storage, + maxRetries: Int = 0 ) : this( AuthenticationAPIClient(auth0), context, auth0, - storage + storage, + maxRetries ) /** @@ -79,18 +83,21 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * @param context a valid context * @param auth0 the Auth0 account information to use * @param storage the storage implementation to use + * @param maxRetries the maximum number of retry attempts for credential renewal when encountering transient errors. Default is 0 (no retries). */ public constructor( apiClient: AuthenticationAPIClient, context: Context, auth0: Auth0, - storage: Storage + storage: Storage, + maxRetries: Int = 0 ) : this( apiClient, storage, CryptoUtil(context, storage, KEY_ALIAS), JWTDecoder(), - auth0.executor + auth0.executor, + maxRetries = maxRetries ) @@ -102,20 +109,23 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * @param storage the storage implementation to use * @param fragmentActivity the FragmentActivity to use for the biometric authentication * @param localAuthenticationOptions the options of type [LocalAuthenticationOptions] to use for the biometric authentication + * @param maxRetries the maximum number of retry attempts for credential renewal when encountering transient errors. Default is 0 (no retries). */ public constructor( context: Context, auth0: Auth0, storage: Storage, fragmentActivity: FragmentActivity, - localAuthenticationOptions: LocalAuthenticationOptions + localAuthenticationOptions: LocalAuthenticationOptions, + maxRetries: Int = 0 ) : this( AuthenticationAPIClient(auth0), context, auth0, storage, fragmentActivity, - localAuthenticationOptions + localAuthenticationOptions, + maxRetries ) @@ -145,6 +155,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * @param storage the storage implementation to use * @param fragmentActivity the FragmentActivity to use for the biometric authentication * @param localAuthenticationOptions the options of type [LocalAuthenticationOptions] to use for the biometric authentication + * @param maxRetries the maximum number of retry attempts for credential renewal when encountering transient errors. Default is 0 (no retries). */ public constructor( apiClient: AuthenticationAPIClient, @@ -152,7 +163,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT auth0: Auth0, storage: Storage, fragmentActivity: FragmentActivity, - localAuthenticationOptions: LocalAuthenticationOptions + localAuthenticationOptions: LocalAuthenticationOptions, + maxRetries: Int = 0 ) : this( apiClient, storage, @@ -161,7 +173,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT auth0.executor, WeakReference(fragmentActivity), localAuthenticationOptions, - DefaultLocalAuthenticationManagerFactory() + DefaultLocalAuthenticationManagerFactory(), + maxRetries ) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index cf3924763..c994e05a8 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -1954,6 +1954,237 @@ public class CredentialsManagerTest { Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) } + // Retry Mechanism Tests + @Test + public fun shouldRetryCredentialRenewalOnNetworkError() { + // Create manager with maxRetries = 2 + val managerWithRetry = CredentialsManager(client, storage, jwtDecoder, serialExecutor, 2) + val spyManager = Mockito.spy(managerWithRetry) + Mockito.doReturn(CredentialsMock.CURRENT_TIME_MS).`when`(spyManager).currentTimeInMillis + Mockito.doAnswer { invocation -> + val idToken = invocation.getArgument(0, String::class.java) + val accessToken = invocation.getArgument(1, String::class.java) + val type = invocation.getArgument(2, String::class.java) + val refreshToken = invocation.getArgument(3, String::class.java) + val expiresAt = invocation.getArgument(4, Date::class.java) + val scope = invocation.getArgument(5, String::class.java) + CredentialsMock.create(idToken, accessToken, type, refreshToken, expiresAt, scope) + }.`when`(spyManager).recreateCredentials( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + any(), + ArgumentMatchers.anyString() + ) + + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Already expired + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // First two attempts fail with network error, third succeeds + val networkException = AuthenticationException("Network error", NetworkErrorException(mock())) + val successfulCredentials = CredentialsMock.create( + "newIdToken", "newAccessToken", "newType", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + Mockito.`when`(request.execute()) + .thenThrow(networkException) + .thenThrow(networkException) + .thenReturn(successfulCredentials) + + spyManager.getCredentials(callback) + + // Verify success after retries + verify(callback, times(1)).onSuccess(credentialsCaptor.capture()) + val capturedCredentials = credentialsCaptor.firstValue + MatcherAssert.assertThat(capturedCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(capturedCredentials.accessToken, Is.`is`("newAccessToken")) + } + + @Test + public fun shouldRetryCredentialRenewalOnRateLimitError() { + // Create manager with maxRetries = 1 + val managerWithRetry = CredentialsManager(client, storage, jwtDecoder, serialExecutor, 1) + val spyManager = Mockito.spy(managerWithRetry) + Mockito.doReturn(CredentialsMock.CURRENT_TIME_MS).`when`(spyManager).currentTimeInMillis + Mockito.doAnswer { invocation -> + val idToken = invocation.getArgument(0, String::class.java) + val accessToken = invocation.getArgument(1, String::class.java) + val type = invocation.getArgument(2, String::class.java) + val refreshToken = invocation.getArgument(3, String::class.java) + val expiresAt = invocation.getArgument(4, Date::class.java) + val scope = invocation.getArgument(5, String::class.java) + CredentialsMock.create(idToken, accessToken, type, refreshToken, expiresAt, scope) + }.`when`(spyManager).recreateCredentials( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + any(), + ArgumentMatchers.anyString() + ) + + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Already expired + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // First attempt fails with 429, second succeeds + val rateLimitException = AuthenticationException(mapOf("error" to "rate_limit"), 429) + val successfulCredentials = CredentialsMock.create( + "newIdToken", "newAccessToken", "newType", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + Mockito.`when`(request.execute()) + .thenThrow(rateLimitException) + .thenReturn(successfulCredentials) + + spyManager.getCredentials(callback) + + // Verify success after retry + verify(callback, times(1)).onSuccess(credentialsCaptor.capture()) + val capturedCredentials = credentialsCaptor.firstValue + MatcherAssert.assertThat(capturedCredentials, Is.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldRetryCredentialRenewalOnServerError() { + // Create manager with maxRetries = 1 + val managerWithRetry = CredentialsManager(client, storage, jwtDecoder, serialExecutor, 1) + val spyManager = Mockito.spy(managerWithRetry) + Mockito.doReturn(CredentialsMock.CURRENT_TIME_MS).`when`(spyManager).currentTimeInMillis + Mockito.doAnswer { invocation -> + val idToken = invocation.getArgument(0, String::class.java) + val accessToken = invocation.getArgument(1, String::class.java) + val type = invocation.getArgument(2, String::class.java) + val refreshToken = invocation.getArgument(3, String::class.java) + val expiresAt = invocation.getArgument(4, Date::class.java) + val scope = invocation.getArgument(5, String::class.java) + CredentialsMock.create(idToken, accessToken, type, refreshToken, expiresAt, scope) + }.`when`(spyManager).recreateCredentials( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + any(), + ArgumentMatchers.anyString() + ) + + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Already expired + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // First attempt fails with 500, second succeeds + val serverException = AuthenticationException(mapOf("error" to "server_error"), 500) + val successfulCredentials = CredentialsMock.create( + "newIdToken", "newAccessToken", "newType", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + Mockito.`when`(request.execute()) + .thenThrow(serverException) + .thenReturn(successfulCredentials) + + spyManager.getCredentials(callback) + + // Verify success after retry + verify(callback, times(1)).onSuccess(credentialsCaptor.capture()) + } + + @Test + public fun shouldNotRetryCredentialRenewalOnNonRetryableError() { + // Create manager with maxRetries = 2 + val managerWithRetry = CredentialsManager(client, storage, jwtDecoder, serialExecutor, 2) + val spyManager = Mockito.spy(managerWithRetry) + Mockito.doReturn(CredentialsMock.CURRENT_TIME_MS).`when`(spyManager).currentTimeInMillis + + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Already expired + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Fail with invalid refresh token (non-retryable) + val invalidTokenException = AuthenticationException("invalid_grant", "Unknown or invalid refresh token.") + Mockito.`when`(request.execute()).thenThrow(invalidTokenException) + + spyManager.getCredentials(callback) + + // Verify failure without retry + verify(callback, times(1)).onFailure(exceptionCaptor.capture()) + verify(request, times(1)).execute() // Only one attempt + } + + @Test + public fun shouldExhaustRetriesAndFailOnPersistentNetworkError() { + // Create manager with maxRetries = 2 + val managerWithRetry = CredentialsManager(client, storage, jwtDecoder, serialExecutor, 2) + val spyManager = Mockito.spy(managerWithRetry) + Mockito.doReturn(CredentialsMock.CURRENT_TIME_MS).`when`(spyManager).currentTimeInMillis + + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Already expired + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // All attempts fail with network error + val networkException = AuthenticationException("Network error", NetworkErrorException(mock())) + Mockito.`when`(request.execute()).thenThrow(networkException) + + spyManager.getCredentials(callback) + + // Verify failure after exhausting retries (1 initial + 2 retries = 3 total) + verify(callback, times(1)).onFailure(exceptionCaptor.capture()) + verify(request, times(3)).execute() + + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldNotRetryWhenMaxRetriesIsZero() { + // Default manager has maxRetries = 0 + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Already expired + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Fail with network error + val networkException = AuthenticationException("Network error", NetworkErrorException(mock())) + Mockito.`when`(request.execute()).thenThrow(networkException) + + manager.getCredentials(callback) + + // Verify failure without retry (only 1 attempt) + verify(callback, times(1)).onFailure(exceptionCaptor.capture()) + verify(request, times(1)).execute() + } + private companion object { private const val ONE_HOUR_SECONDS = (60 * 60).toLong() }