Skip to content

Commit b67744c

Browse files
authored
feat: Expose isRetryable property on CredentialsManagerException, ApiException, and WebAuthenticationException (#786)
1 parent f4018bd commit b67744c

24 files changed

Lines changed: 397 additions & 14 deletions

auth0_flutter/EXAMPLES.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,16 @@ try {
345345
final credentials = await auth0.webAuthentication().login();
346346
// ...
347347
} on WebAuthenticationException catch (e) {
348-
print(e);
348+
if (e.isRetryable) {
349+
// Transient error (e.g. network issue) — safe to retry
350+
} else {
351+
print(e);
352+
}
349353
}
350354
```
351355

356+
The `isRetryable` property indicates whether the error is transient (e.g. a network outage) and the operation can be retried.
357+
352358
</details>
353359

354360
<details>
@@ -521,6 +527,27 @@ print(e);
521527
}
522528
```
523529

530+
#### Retryable errors
531+
532+
The `isRetryable` property on `CredentialsManagerException` indicates whether the error is transient and the operation can be retried. When `true`, the failure is likely due to a temporary condition such as a network outage. When `false`, the failure is permanent (e.g. an invalid refresh token) and retrying will not help — you should log the user out instead.
533+
534+
```dart
535+
try {
536+
final credentials = await auth0.credentialsManager.credentials();
537+
// ...
538+
} on CredentialsManagerException catch (e) {
539+
if (e.isRetryable) {
540+
// Transient error (e.g. network issue) — safe to retry
541+
print("Temporary error, retrying...");
542+
} else {
543+
// Permanent error — log the user out
544+
print("Credentials cannot be renewed: ${e.message}");
545+
}
546+
}
547+
```
548+
549+
> The `isRetryable` property is available on all exception types (`CredentialsManagerException`, `ApiException`, `WebAuthenticationException`) across Android and iOS/macOS. It returns `true` when the underlying failure is network-related, indicating the operation may succeed on retry.
550+
524551
### Native to Web SSO
525552

526553
Native to Web SSO allows authenticated users in your native mobile application to seamlessly transition to your web application without requiring them to log in again. This is achieved by exchanging a refresh token for a Session Transfer Token, which can then be used to establish a session in the web application.
@@ -891,10 +918,16 @@ try {
891918
connectionOrRealm: connection);
892919
// ...
893920
} on ApiException catch (e) {
894-
print(e);
921+
if (e.isRetryable) {
922+
// Transient error (e.g. network issue) — safe to retry
923+
} else {
924+
print(e);
925+
}
895926
}
896927
```
897928

929+
The `isRetryable` property indicates whether the error is transient (e.g. a network outage) and the operation can be retried. It returns `true` when `isNetworkError` is `true`.
930+
898931
[Go up ⤴](#examples)
899932

900933
## 🌐📱 Organizations
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.auth0.auth0_flutter
2+
3+
import com.auth0.android.authentication.AuthenticationException
4+
import com.auth0.android.authentication.storage.CredentialsManagerException
5+
6+
fun CredentialsManagerException.toMap(): Map<String, Any> {
7+
val exceptionCause = this.cause
8+
val isRetryable = when (exceptionCause) {
9+
is AuthenticationException -> exceptionCause.isNetworkError
10+
else -> false
11+
}
12+
13+
val map = mutableMapOf<String, Any>("_isRetryable" to isRetryable)
14+
if (exceptionCause != null) {
15+
map["cause"] = exceptionCause.toString()
16+
map["causeStackTrace"] = exceptionCause.stackTraceToString()
17+
}
18+
return map
19+
}

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class GetCredentialsRequestHandler : CredentialsManagerRequestHandler {
3232
credentialsManager.getCredentials(scope, minTtl, parameters, object:
3333
Callback<Credentials, CredentialsManagerException> {
3434
override fun onFailure(exception: CredentialsManagerException) {
35-
result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception)
35+
result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception.toMap())
3636
}
3737

3838
override fun onSuccess(credentials: Credentials) {

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.auth0.android.authentication.storage.SecureCredentialsManager
66
import com.auth0.android.callback.Callback
77
import com.auth0.android.result.SSOCredentials
88
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
9+
import com.auth0.auth0_flutter.toMap
910
import io.flutter.plugin.common.MethodChannel
1011

1112
class GetSSOCredentialsRequestHandler : CredentialsManagerRequestHandler {
@@ -22,7 +23,7 @@ class GetSSOCredentialsRequestHandler : CredentialsManagerRequestHandler {
2223
credentialsManager.getSsoCredentials(parameters, object :
2324
Callback<SSOCredentials, CredentialsManagerException> {
2425
override fun onFailure(exception: CredentialsManagerException) {
25-
result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception)
26+
result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception.toMap())
2627
}
2728

2829
override fun onSuccess(credentials: SSOCredentials) {

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class RenewCredentialsRequestHandler : CredentialsManagerRequestHandler {
2424
credentialsManager.getCredentials(null, 0, parameters, true, object :
2525
Callback<Credentials, CredentialsManagerException> {
2626
override fun onFailure(exception: CredentialsManagerException) {
27-
result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception)
27+
result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception.toMap())
2828
}
2929

3030
override fun onSuccess(credentials: Credentials) {

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,12 @@ class LoginWebAuthRequestHandler(
8080

8181
builder.start(context, object : Callback<Credentials, AuthenticationException> {
8282
override fun onFailure(exception: AuthenticationException) {
83-
result.error(exception.getCode(), exception.getDescription(), exception)
83+
val details = mutableMapOf<String, Any>("_isRetryable" to exception.isNetworkError)
84+
exception.cause?.let {
85+
details["cause"] = it.toString()
86+
details["causeStackTrace"] = it.stackTraceToString()
87+
}
88+
result.error(exception.getCode(), exception.getDescription(), details)
8489
}
8590

8691
override fun onSuccess(credentials: Credentials) {

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ class LogoutWebAuthRequestHandler(private val builderResolver: (MethodCallReques
4646

4747
builder.start(context, object : Callback<Void?, AuthenticationException> {
4848
override fun onFailure(exception: AuthenticationException) {
49-
result.error(exception.getCode(), exception.getDescription(), exception)
49+
val details = mutableMapOf<String, Any>("_isRetryable" to exception.isNetworkError)
50+
exception.cause?.let {
51+
details["cause"] = it.toString()
52+
details["causeStackTrace"] = it.stackTraceToString()
53+
}
54+
result.error(exception.getCode(), exception.getDescription(), details)
5055
}
5156

5257
override fun onSuccess(res: Void?) {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.auth0.auth0_flutter
2+
3+
import com.auth0.android.authentication.AuthenticationException
4+
import com.auth0.android.authentication.storage.CredentialsManagerException
5+
import org.hamcrest.CoreMatchers.equalTo
6+
import org.hamcrest.MatcherAssert.assertThat
7+
import org.junit.Test
8+
import org.junit.runner.RunWith
9+
import org.mockito.Mockito.`when`
10+
import org.mockito.Mockito.mock
11+
import org.robolectric.RobolectricTestRunner
12+
13+
@RunWith(RobolectricTestRunner::class)
14+
class CredentialsManagerExceptionExtensionsTest {
15+
16+
@Test
17+
fun `should set isRetryable to false when cause is null`() {
18+
val exception = mock(CredentialsManagerException::class.java)
19+
`when`(exception.cause).thenReturn(null)
20+
21+
val map = exception.toMap()
22+
23+
assertThat(map["_isRetryable"], equalTo(false))
24+
}
25+
26+
@Test
27+
fun `should set isRetryable to true when cause is a network AuthenticationException`() {
28+
val authException = mock(AuthenticationException::class.java)
29+
`when`(authException.isNetworkError).thenReturn(true)
30+
31+
val exception = mock(CredentialsManagerException::class.java)
32+
`when`(exception.cause).thenReturn(authException)
33+
34+
val map = exception.toMap()
35+
36+
assertThat(map["_isRetryable"], equalTo(true))
37+
}
38+
39+
@Test
40+
fun `should set isRetryable to false when cause is a non-network AuthenticationException`() {
41+
val authException = mock(AuthenticationException::class.java)
42+
`when`(authException.isNetworkError).thenReturn(false)
43+
44+
val exception = mock(CredentialsManagerException::class.java)
45+
`when`(exception.cause).thenReturn(authException)
46+
47+
val map = exception.toMap()
48+
49+
assertThat(map["_isRetryable"], equalTo(false))
50+
}
51+
52+
@Test
53+
fun `should set isRetryable to false when cause is a generic exception`() {
54+
val exception = mock(CredentialsManagerException::class.java)
55+
`when`(exception.cause).thenReturn(RuntimeException("generic error"))
56+
57+
val map = exception.toMap()
58+
59+
assertThat(map["_isRetryable"], equalTo(false))
60+
}
61+
62+
@Test
63+
fun `should include cause in map when cause is present`() {
64+
val cause = RuntimeException("network error")
65+
val exception = mock(CredentialsManagerException::class.java)
66+
`when`(exception.cause).thenReturn(cause)
67+
68+
val map = exception.toMap()
69+
70+
assertThat(map["cause"], equalTo(cause.toString()))
71+
}
72+
73+
@Test
74+
fun `should include causeStackTrace in map when cause is present`() {
75+
val cause = RuntimeException("network error")
76+
val exception = mock(CredentialsManagerException::class.java)
77+
`when`(exception.cause).thenReturn(cause)
78+
79+
val map = exception.toMap()
80+
81+
assertThat(map["causeStackTrace"], equalTo(cause.stackTraceToString()))
82+
}
83+
84+
@Test
85+
fun `should not include cause or causeStackTrace in map when cause is null`() {
86+
val exception = mock(CredentialsManagerException::class.java)
87+
`when`(exception.cause).thenReturn(null)
88+
89+
val map = exception.toMap()
90+
91+
assertThat(map.containsKey("cause"), equalTo(false))
92+
assertThat(map.containsKey("causeStackTrace"), equalTo(false))
93+
}
94+
}

auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,36 @@ class LoginWebAuthRequestHandlerTest {
305305
val mockRequest = MethodCallRequest(mockAccount, hashMapOf<String, Any>())
306306
handler.handle(mock(), mockRequest, mockResult)
307307

308-
verify(mockResult).error("code", "description", exception)
308+
verify(mockResult).error(eq("code"), eq("description"), eq(mapOf("_isRetryable" to false)))
309+
}
310+
311+
@Test
312+
fun `returns cause and causeStackTrace in error details when cause is present`() {
313+
val builder = mock<WebAuthProvider.Builder>()
314+
val mockResult = mock<Result>()
315+
val cause = RuntimeException("network error")
316+
val exception = mock<AuthenticationException>()
317+
whenever(exception.getCode()).thenReturn("code")
318+
whenever(exception.getDescription()).thenReturn("description")
319+
whenever(exception.isNetworkError).thenReturn(true)
320+
whenever(exception.cause).thenReturn(cause)
321+
322+
doAnswer { invocation ->
323+
val cb = invocation.getArgument<Callback<Credentials, AuthenticationException>>(1)
324+
cb.onFailure(exception)
325+
}.`when`(builder).start(any(), any())
326+
327+
val handler = LoginWebAuthRequestHandler { _ -> builder }
328+
val mockAccount = mock<Auth0>()
329+
val mockRequest = MethodCallRequest(mockAccount, hashMapOf<String, Any>())
330+
handler.handle(mock(), mockRequest, mockResult)
331+
332+
verify(mockResult).error(eq("code"), eq("description"), check {
333+
val map = it as Map<*, *>
334+
assertThat(map["_isRetryable"], equalTo(true))
335+
assertThat(map["cause"], equalTo(cause.toString()))
336+
assertThat(map["causeStackTrace"], equalTo(cause.stackTraceToString()))
337+
})
309338
}
310339

311340
@Test

auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import com.auth0.android.provider.WebAuthProvider
99
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
1010
import com.auth0.auth0_flutter.request_handlers.web_auth.LogoutWebAuthRequestHandler
1111
import io.flutter.plugin.common.MethodChannel.Result
12+
import org.hamcrest.CoreMatchers.equalTo
13+
import org.hamcrest.MatcherAssert.assertThat
1214
import org.junit.Test
1315
import org.junit.runner.RunWith
1416
import org.mockito.kotlin.*
@@ -122,7 +124,34 @@ class LogoutWebAuthRequestHandlerTest {
122124

123125
handler.handle(mock(), MethodCallRequest(Auth0.getInstance("test-client", "test-domain"), mock()), mockResult)
124126

125-
verify(mockResult).error("code", "description", exception)
127+
verify(mockResult).error(eq("code"), eq("description"), eq(mapOf("_isRetryable" to false)))
128+
}
129+
130+
@Test
131+
fun `returns cause and causeStackTrace in error details when cause is present`() {
132+
val mockBuilder = mock<WebAuthProvider.LogoutBuilder>()
133+
val mockResult = mock<Result>()
134+
val handler = LogoutWebAuthRequestHandler { mockBuilder }
135+
val cause = RuntimeException("network error")
136+
val exception = mock<AuthenticationException>()
137+
whenever(exception.getCode()).thenReturn("code")
138+
whenever(exception.getDescription()).thenReturn("description")
139+
whenever(exception.isNetworkError).thenReturn(true)
140+
whenever(exception.cause).thenReturn(cause)
141+
142+
doAnswer { invocation ->
143+
val callback = invocation.getArgument<Callback<Void?, AuthenticationException>>(1)
144+
callback.onFailure(exception)
145+
}.`when`(mockBuilder).start(any(), any())
146+
147+
handler.handle(mock(), MethodCallRequest(Auth0.getInstance("test-client", "test-domain"), mock()), mockResult)
148+
149+
verify(mockResult).error(eq("code"), eq("description"), check {
150+
val map = it as Map<*, *>
151+
assertThat(map["_isRetryable"], equalTo(true))
152+
assertThat(map["cause"], equalTo(cause.toString()))
153+
assertThat(map["causeStackTrace"], equalTo(cause.stackTraceToString()))
154+
})
126155
}
127156

128157
@Test

0 commit comments

Comments
 (0)