Skip to content

Commit 7d980af

Browse files
feat: Add automatic retry mechanism for credential renewal
1 parent b717b52 commit 7d980af

File tree

3 files changed

+103
-3
lines changed

3 files changed

+103
-3
lines changed

Auth0/Auth0APIError.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ extension Auth0APIError {
106106
]
107107
}
108108

109+
/// Determines if the error is retryable based on its type.
110+
///
111+
/// Returns `true` for:
112+
/// - Network errors (as determined by ``isNetworkError``)
113+
/// - Rate limiting errors (HTTP 429)
114+
/// - Server errors (HTTP 5xx)
115+
///
116+
/// - Returns: `true` if the error is retryable, `false` otherwise.
117+
var isRetryable: Bool {
118+
// Retry on network errors
119+
if self.isNetworkError {
120+
return true
121+
}
122+
123+
// Retry on rate limiting (429) or server errors (5xx)
124+
let statusCode = self.statusCode
125+
return statusCode == 429 || (500...599).contains(statusCode)
126+
}
127+
109128
}
110129

111130
func json(_ data: Data?) -> Any? {

Auth0/CredentialsManager.swift

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public struct CredentialsManager: Sendable {
3535

3636
private let storeKey: String
3737
private let authentication: Authentication
38+
private let maxRetries: Int
3839
#if WEB_AUTH_PLATFORM
3940
var bioAuth: BioAuthentication?
4041
// Biometric session management - using a class to allow mutation in non-mutating methods
@@ -57,12 +58,15 @@ public struct CredentialsManager: Sendable {
5758
/// - authentication: Auth0 Authentication API client.
5859
/// - storeKey: Key used to store user credentials in the Keychain. Defaults to 'credentials'.
5960
/// - storage: The ``CredentialsStorage`` instance used to manage credentials storage. Defaults to a standard `SimpleKeychain` instance.
61+
/// - maxRetries: Maximum number of retry attempts for credential renewal on transient errors. Defaults to 0.
6062
public init(authentication: Authentication,
6163
storeKey: String = "credentials",
62-
storage: CredentialsStorage = SimpleKeychain()) {
64+
storage: CredentialsStorage = SimpleKeychain(),
65+
maxRetries: Int = 0) {
6366
self.storeKey = storeKey
6467
self.authentication = authentication
6568
self.sendableStorage = SendableBox(value: storage)
69+
self.maxRetries = max(0, maxRetries)
6670
}
6771

6872
/// Retrieves the user information from the Keychain synchronously, without checking if the credentials are expired.
@@ -743,6 +747,23 @@ public struct CredentialsManager: Sendable {
743747
headers: [String: String],
744748
forceRenewal: Bool,
745749
callback: @escaping (CredentialsManagerResult<Credentials>) -> Void) {
750+
self.retrieveCredentialsWithRetry(scope: scope,
751+
minTTL: minTTL,
752+
parameters: parameters,
753+
headers: headers,
754+
forceRenewal: forceRenewal,
755+
retryCount: 0,
756+
callback: callback)
757+
}
758+
759+
// swiftlint:disable:next function_parameter_count function_body_length
760+
private func retrieveCredentialsWithRetry(scope: String?,
761+
minTTL: Int,
762+
parameters: [String: Any],
763+
headers: [String: String],
764+
forceRenewal: Bool,
765+
retryCount: Int,
766+
callback: @escaping (CredentialsManagerResult<Credentials>) -> Void) {
746767
SynchronizationBarrier.shared.execute { complete in
747768
guard let credentials = self.retrieveCredentials() else {
748769
complete()
@@ -782,13 +803,34 @@ public struct CredentialsManager: Sendable {
782803
callback(.success(newCredentials))
783804
}
784805
case .failure(let error):
785-
complete()
786-
callback(.failure(CredentialsManagerError(code: .renewFailed, cause: error)))
806+
// Check if we should retry based on error type and retry count
807+
if self.shouldRetryRenewal(for: error, retryCount: retryCount) {
808+
complete()
809+
// Calculate exponential backoff delay: 0.5s, 1s, 2s, etc.
810+
let delay = pow(2.0, Double(retryCount)) * 0.5
811+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) {
812+
self.retrieveCredentialsWithRetry(scope: scope,
813+
minTTL: minTTL,
814+
parameters: parameters,
815+
headers: headers,
816+
forceRenewal: forceRenewal,
817+
retryCount: retryCount + 1,
818+
callback: callback)
819+
}
820+
} else {
821+
complete()
822+
callback(.failure(CredentialsManagerError(code: .renewFailed, cause: error)))
823+
}
787824
}
788825
}
789826
}
790827
}
791828

829+
private func shouldRetryRenewal(for error: AuthenticationError, retryCount: Int) -> Bool {
830+
guard retryCount < self.maxRetries else { return false }
831+
return error.isRetryable
832+
}
833+
792834
private func retrieveSSOCredentials(parameters: [String: Any],
793835
headers: [String: String],
794836
callback: @escaping (CredentialsManagerResult<SSOCredentials>) -> Void) {

EXAMPLES.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,45 @@ credentialsManager
484484
> [!CAUTION]
485485
> To ensure that no concurrent renewal requests get made, do not call this method from multiple Credentials Manager instances. The Credentials Manager cannot synchronize requests across instances.
486486
487+
#### Automatic retry on transient errors
488+
489+
The Credentials Manager includes automatic retry logic for credential renewal when transient errors occur. This helps handle scenarios where network requests fail temporarily, such as:
490+
491+
- Network connectivity issues (timeouts, connection lost, DNS failures)
492+
- Rate limiting responses (HTTP 429)
493+
- Server errors (HTTP 5xx)
494+
495+
**How it works:**
496+
497+
When a renewal request fails due to a transient error, the Credentials Manager will automatically retry the request with exponential backoff (0.5s, 1s, 2s, 4s, etc.). This addresses the following scenario:
498+
499+
1. Request A calls `credentials()` and starts a token refresh
500+
2. Request A successfully hits the server and gets new credentials
501+
3. Request A fails on the way back (network issue), never reaching the client
502+
4. Later, request B retries with the same (old) refresh token
503+
504+
To fully leverage the retry mechanism, ensure your Auth0 tenant's **Rotation Overlap Period** is set to at least 180 seconds. This overlap window ensures the old refresh token remains valid during retry attempts even if the backend resource was already updated. You can configure this setting in your Auth0 Dashboard under **Applications > [Your Application] > Settings > Refresh Token Rotation**.
505+
506+
**Configure retry behavior:**
507+
508+
By default, retries are disabled. You can enable retries by specifying a maximum retry count when creating the Credentials Manager. It is advisable to set a maximum of 2 retries, which provides sufficient resilience without introducing excessive delays or unnecessary network requests.
509+
510+
```swift
511+
// Enable up to 2 retry attempts (recommended maximum)
512+
let credentialsManager = CredentialsManager(
513+
authentication: Auth0.authentication(),
514+
maxRetries: 2
515+
)
516+
```
517+
518+
519+
**Important considerations:**
520+
521+
- Retries only occur for transient errors (network issues, rate limiting, server errors)
522+
- Permanent errors (invalid refresh token, authorization failures) will not be retried
523+
- Each retry uses exponential backoff to avoid overwhelming the server
524+
- The 180-second refresh token overlap window ensures retries can succeed even after a successful backend renewal
525+
487526
### Renew stored credentials
488527

489528
The `credentials()` method automatically renews the stored credentials when needed, using the [refresh token](https://auth0.com/docs/secure/tokens/refresh-tokens). However, you can also force a renewal using the `renew()` method. **This method is thread-safe**.

0 commit comments

Comments
 (0)