fix(backoff): allow retry when device clock moves backward (#137)

Signed-off-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com>
Co-authored-by: Andrew Heard <andrewheard@google.com>
This commit is contained in:
Nick CookeandAndrew Heard authored and GitHub committed 2026-11-06 13:38:26 -05:00
1 parent 37f72580ef
commit 4639d4e97e
5 files changed
+93 -4

No files matched your search

@@ -20,6 +20,11 @@ public class AppCheckCoreToken: NSObject, @unchecked Sendable {
@objc public let expirationDate: Date
@objc public let receivedAtDate: Date
/// Creates an App Check token.
/// - Parameters:
/// - token: A Firebase App Check token string.
/// - expirationDate: The token's expiration date in the device's local clock time.
/// - receivedAtDate: The date the token was received in the device's local clock time.
@objc(initWithToken:expirationDate:receivedAtDate:)
public init(token: String, expirationDate: Date, receivedAt receivedAtDate: Date) {
self.token = token
@@ -28,6 +33,10 @@ public class AppCheckCoreToken: NSObject, @unchecked Sendable {
super.init()
}
/// Creates an App Check token.
/// - Parameters:
/// - token: A Firebase App Check token string.
/// - expirationDate: The token's expiration date in the device's local clock time.
@objc(initWithToken:expirationDate:)
public convenience init(token: String, expirationDate: Date) {
self.init(token: token, expirationDate: expirationDate, receivedAt: Date())
@@ -141,6 +141,11 @@ public class AppCheckCoreBackoffWrapper: NSObject, AppCheckCoreBackoffWrapperPro
private func hasTimeIntervalPassedSinceLastFailure(_ timeInterval: TimeInterval) -> Bool {
guard let failureDate = lastFailure?.finishDate else { return true }
let timeSinceFailure = dateProvider().timeIntervalSince(failureDate)
// A negative interval means the device clock moved backward since the failure was recorded,
// so `failureDate` can no longer bound the backoff window reliably.
if timeSinceFailure < 0 {
return true
}
return timeSinceFailure >= timeInterval
}
@@ -212,6 +212,76 @@ class AppCheckCoreBackoffWrapperTests: XCTestCase {
}
}
func testBackoffWhenClockMovesBackwardAllowsRetryAndReanchorsBackoff() async throws {
// Simulate a failure recorded while the device clock was 48h ahead.
let skew: TimeInterval = 48 * 60 * 60
let realNow = Date()
currentDate = realNow.addingTimeInterval(skew)
setUpOperationError()
setUpErrorHandler(with: .exponential)
do {
_ = try await backoffWrapper.applyBackoffToOperation(
operationProvider,
errorHandler: errorHandler
)
XCTFail("Expected error")
} catch {
XCTAssertEqual(error as NSError, operationResult as? NSError)
}
await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0)
// Clock is corrected back to real time (`timeSinceFailure` is now negative).
// The wrapper should allow an immediate retry rather than locking out for 48h; if that retry
// also fails, `finishDate` re-anchors to the corrected clock and normal backoff resumes.
currentDate = realNow
setUpOperationError()
setUpErrorHandler(with: .exponential)
do {
_ = try await backoffWrapper.applyBackoffToOperation(
operationProvider,
errorHandler: errorHandler
)
XCTFail("Expected error")
} catch {
XCTAssertEqual(error as NSError, operationResult as? NSError)
}
await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0)
// Subsequent call 0.5s later is now properly throttled against the re-anchored failure date.
currentDate = realNow.addingTimeInterval(0.5)
setUpOperationSuccess()
setUpErrorHandler(with: .none)
operationFinishExpectation.isInverted = true
errorHandlerExpectation.isInverted = true
do {
_ = try await backoffWrapper.applyBackoffToOperation(
operationProvider,
errorHandler: errorHandler
)
XCTFail("Expected backoff error")
} catch {
XCTAssertTrue(isBackoffError(error as NSError))
}
await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 0.1)
// Once the re-anchored backoff window for attempt 2 ([2.0s, 3.0s)) elapses, retry succeeds.
currentDate = realNow.addingTimeInterval(3.5)
setUpOperationSuccess()
setUpErrorHandler(with: .none)
errorHandlerExpectation.isInverted = true
let result = try await backoffWrapper.applyBackoffToOperation(
operationProvider,
errorHandler: errorHandler
)
await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 0.1)
XCTAssertEqual(result as? NSObject, operationResult as? NSObject)
}
// MARK: - Helpers
private func setUpErrorHandler(with backoffType: AppCheckCoreBackoffType) {
@@ -132,6 +132,9 @@ class AppCheckCoreTests: XCTestCase {
}
XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1)
XCTAssertEqual(fakeStorage.lastSetToken, expectedToken)
XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 1)
XCTAssertEqual(fakeTokenDelegate.lastToken, expectedToken)
}
func testGetToken_AppCheckProviderError() async {
+6 -4
View File
@@ -12,14 +12,16 @@
local storage. More sources may be added later; Swift `switch` statements
over `AppCheckCoreDebugTokenSource` should include `@unknown default`.
(#134)
- [fixed] Fixed an issue where a cached token fetched while the device clock was ahead kept
being served after the clock was corrected, because its stored dates inherited the offset.
Cached tokens received in the future are now refreshed.
(https://github.com/firebase/firebase-ios-sdk/issues/16939)
- [fixed] Fixed an issue where a cached token fetched while the device clock was
ahead kept being served after the clock was corrected, because its stored
dates inherited the offset. Cached tokens received in the future are now
refreshed. (https://github.com/firebase/firebase-ios-sdk/issues/16939)
- [fixed] Fixed an issue where a debug token exchange using the
`AppCheckDebugToken` or `FIRAAppCheckDebugToken` environment variable
updated the locally generated debug token's registered state, which could
suppress or repeat its registration instructions. (#135)
- [fixed] Fixed an issue where provider request backoff could block retries if
the device clock moved backward after a failed request. (#137)
# 12.0.0
- [changed] Ported AppCheckCore from Objective-C to Swift.