From 8e2ea49d9dd4d414ba6e6ccc734796a05ef7bfbc Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 20 Jul 2026 19:16:02 +0100 Subject: [PATCH] SDK-562 Stamp current auth token when executing offline tasks Offline-queued requests persisted the JWT inside the task at enqueue time and replayed it on every retry. A task queued while the token was expired kept failing with a 401 after refresh, survived app restarts, and paused the JWT tasks behind it. The task processor now overwrites the persisted token with the current one from AuthManager before building the request, matching online behavior and healing tasks already stuck in the queue. A nil live token keeps the persisted one. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + .../Internal/IterableAPICallRequest.swift | 2 +- .../IterableAPICallTaskProcessor.swift | 10 ++- swift-sdk/Internal/IterableTaskRunner.swift | 10 ++- .../DependencyContainerProtocol.swift | 9 +- .../TaskProcessorTests.swift | 29 ++++++- .../TaskRunnerTests.swift | 84 +++++++++++++++++-- 7 files changed, 129 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d04935d..6e6f8d419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Fixed +- Fixed offline-queued requests replaying an expired JWT forever. Tasks persisted while the token was expired kept the stale token in their payload, so they failed with a 401 on every retry even after a successful refresh, and could block the rest of the offline queue. The task processor now stamps the current auth token at execution time, matching online behavior, which also heals tasks already stuck in the queue. ## [6.7.4] ### Added diff --git a/swift-sdk/Internal/IterableAPICallRequest.swift b/swift-sdk/Internal/IterableAPICallRequest.swift index 640f6bc84..c3a1b6e70 100644 --- a/swift-sdk/Internal/IterableAPICallRequest.swift +++ b/swift-sdk/Internal/IterableAPICallRequest.swift @@ -9,7 +9,7 @@ import Foundation struct IterableAPICallRequest { let apiKey: String let endpoint: String - let authToken: String? + var authToken: String? let deviceMetadata: DeviceMetadata let iterableRequest: IterableRequest diff --git a/swift-sdk/Internal/IterableAPICallTaskProcessor.swift b/swift-sdk/Internal/IterableAPICallTaskProcessor.swift index d46198675..f7fa2bf1a 100644 --- a/swift-sdk/Internal/IterableAPICallTaskProcessor.swift +++ b/swift-sdk/Internal/IterableAPICallTaskProcessor.swift @@ -10,10 +10,12 @@ struct IterableAPICallTaskProcessor: IterableTaskProcessor { init(networkSession: NetworkSessionProtocol, dateProvider: DateProviderProtocol = SystemDateProvider(), - autoRetry: Bool = false) { + autoRetry: Bool = false, + authManager: IterableAuthManagerProtocol? = nil) { self.networkSession = networkSession self.dateProvider = dateProvider self.autoRetry = autoRetry + self.authManager = authManager } func process(task: IterableTask) throws -> Pending { @@ -22,7 +24,10 @@ struct IterableAPICallTaskProcessor: IterableTaskProcessor { return IterableTaskError.createErroredFuture(reason: "expecting data") } - let decodedIterableRequest = try JSONDecoder().decode(IterableAPICallRequest.self, from: data) + var decodedIterableRequest = try JSONDecoder().decode(IterableAPICallRequest.self, from: data) + if let authToken = authManager?.getAuthToken() { + decodedIterableRequest.authToken = authToken + } let iterableRequest = decodedIterableRequest.addingCreatedAt(task.scheduledAt) guard let urlRequest = iterableRequest.convertToURLRequest(sentAt: dateProvider.currentDate, processorType: .offline) else { @@ -52,6 +57,7 @@ struct IterableAPICallTaskProcessor: IterableTaskProcessor { } private let dateProvider: DateProviderProtocol + private let authManager: IterableAuthManagerProtocol? /// Returns true for permanent client errors (4xx, excluding 429) that should NOT be retried. /// Network-level errors (no HTTP status), server errors (5xx), and 429 (rate limit) are transient. diff --git a/swift-sdk/Internal/IterableTaskRunner.swift b/swift-sdk/Internal/IterableTaskRunner.swift index 0ca273082..c85db7191 100644 --- a/swift-sdk/Internal/IterableTaskRunner.swift +++ b/swift-sdk/Internal/IterableTaskRunner.swift @@ -14,7 +14,8 @@ class IterableTaskRunner: NSObject { connectivityManager: NetworkConnectivityManager = NetworkConnectivityManager(), dateProvider: DateProviderProtocol = SystemDateProvider(), autoRetry: Bool = false, - connectivityDebounceInterval: TimeInterval = 3.0) { + connectivityDebounceInterval: TimeInterval = 3.0, + authManager: IterableAuthManagerProtocol? = nil) { ITBInfo() self.networkSession = networkSession self.healthMonitor = healthMonitor @@ -25,6 +26,7 @@ class IterableTaskRunner: NSObject { self.connectivityManager = connectivityManager self.persistenceContext = persistenceContextProvider.newBackgroundContext() self.autoRetry = autoRetry + self.authManager = authManager super.init() @@ -280,7 +282,10 @@ class IterableTaskRunner: NSObject { switch task.type { case .apiCall: - let processor = IterableAPICallTaskProcessor(networkSession: networkSession, dateProvider: dateProvider, autoRetry: autoRetry) + let processor = IterableAPICallTaskProcessor(networkSession: networkSession, + dateProvider: dateProvider, + autoRetry: autoRetry, + authManager: authManager) return processAPICallTask(processor: processor, task: task) } } @@ -425,6 +430,7 @@ class IterableTaskRunner: NSObject { private let connectivityManager: NetworkConnectivityManager private var running = false private(set) var autoRetry: Bool + private let authManager: IterableAuthManagerProtocol? func setAutoRetry(_ value: Bool) { persistenceContext.perform { [weak self] in diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index 61337fc3d..645437f68 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -113,7 +113,8 @@ extension DependencyContainerProtocol { healthMonitor: healthMonitor!), taskRunner: createTaskRunner(persistenceContextProvider: persistenceContextProvider, healthMonitor: healthMonitor!, - autoRetry: localStorage.autoRetry), + autoRetry: localStorage.autoRetry, + authManager: authManager), notificationCenter: notificationCenter) @@ -162,13 +163,15 @@ extension DependencyContainerProtocol { private func createTaskRunner(persistenceContextProvider: IterablePersistenceContextProvider, healthMonitor: HealthMonitor, - autoRetry: Bool = false) -> IterableTaskRunner { + autoRetry: Bool = false, + authManager: IterableAuthManagerProtocol? = nil) -> IterableTaskRunner { IterableTaskRunner(networkSession: networkSession, persistenceContextProvider: persistenceContextProvider, healthMonitor: healthMonitor, notificationCenter: notificationCenter, connectivityManager: NetworkConnectivityManager(), - autoRetry: autoRetry) + autoRetry: autoRetry, + authManager: authManager) } func createUnknownUserMerge(apiClient: ApiClient, unknownUserManager: UnknownUserManagerProtocol, localStorage: LocalStorageProtocol) -> UnknownUserMergeProtocol { diff --git a/tests/offline-events-tests/TaskProcessorTests.swift b/tests/offline-events-tests/TaskProcessorTests.swift index 238a4ae72..dec2f2b72 100644 --- a/tests/offline-events-tests/TaskProcessorTests.swift +++ b/tests/offline-events-tests/TaskProcessorTests.swift @@ -88,6 +88,29 @@ class TaskProcessorTests: XCTestCase { wait(for: [expectation1], timeout: 15.0) } + func testUsesCurrentAuthTokenWhenProcessingPersistedTask() throws { + let persistedToken = "token-a" + let currentToken = "token-b" + let task = try createSampleTask(authToken: persistedToken)! + let authManager = MockAuthManager() + authManager.token = currentToken + + let requestExpectation = expectation(description: #function) + let networkSession = MockNetworkSession(statusCode: 200) + networkSession.requestCallback = { request in + XCTAssertEqual(request.value(forHTTPHeaderField: JsonKey.Header.authorization), "Bearer \(currentToken)") + requestExpectation.fulfill() + } + + let processor = IterableAPICallTaskProcessor(networkSession: networkSession, + authManager: authManager) + try processor.process(task: task) + + wait(for: [requestExpectation], timeout: 5.0) + try persistenceProvider.mainQueueContext().delete(task: task) + try persistenceProvider.mainQueueContext().save() + } + func testNetworkUnavailable() throws { let expectation1 = expectation(description: #function) let task = try createSampleTask()! @@ -306,7 +329,9 @@ class TaskProcessorTests: XCTestCase { wait(for: [expectation1], timeout: 5.0) } - private func createSampleTask(scheduledAt: Date = Date(), requestedAt: Date = Date()) throws -> IterableTask? { + private func createSampleTask(scheduledAt: Date = Date(), + requestedAt: Date = Date(), + authToken: String? = nil) throws -> IterableTask? { let apiKey = "test-api-key" let email = "user@example.com" let eventName = "CustomEvent1" @@ -322,7 +347,7 @@ class TaskProcessorTests: XCTestCase { let apiCallRequest = IterableAPICallRequest(apiKey: apiKey, endpoint: Endpoint.api, - authToken: auth.authToken, + authToken: authToken, deviceMetadata: deviceMetadata, iterableRequest: trackEventRequest) let data = try JSONEncoder().encode(apiCallRequest) diff --git a/tests/offline-events-tests/TaskRunnerTests.swift b/tests/offline-events-tests/TaskRunnerTests.swift index 0b4e7d96f..efae734e6 100644 --- a/tests/offline-events-tests/TaskRunnerTests.swift +++ b/tests/offline-events-tests/TaskRunnerTests.swift @@ -510,9 +510,71 @@ class TaskRunnerTests: XCTestCase { taskRunner.stop() } + func testJWTAuthFailureRetriesWithRefreshedToken() throws { + let expiredToken = "expired-token" + let freshToken = "fresh-token" + let jwtErrorData = ["code": "InvalidJwtPayload"].toJsonData() + let networkSession = MockNetworkSession(statusCode: 401, data: jwtErrorData) + var authorizationHeaders = [String?]() + networkSession.requestCallback = { request in + authorizationHeaders.append(request.value(forHTTPHeaderField: JsonKey.Header.authorization)) + } + + let notificationCenter = MockNotificationCenter() + let retryExpectation = expectation(description: "retry notification received") + let retryReference = notificationCenter.addCallback(forNotification: .iterableTaskFinishedWithRetry) { _ in + retryExpectation.fulfill() + } + + let healthMonitor = HealthMonitor(dataProvider: HealthMonitorDataProvider(maxTasks: 1000, + persistenceContextProvider: persistenceContextProvider), + dateProvider: SystemDateProvider(), + networkSession: networkSession) + let authManager = MockAuthManager() + authManager.token = expiredToken + let taskRunner = IterableTaskRunner(networkSession: networkSession, + persistenceContextProvider: persistenceContextProvider, + healthMonitor: healthMonitor, + notificationCenter: notificationCenter, + timeInterval: 0.5, + autoRetry: true, + authManager: authManager) + taskRunner.start() + + let scheduler = IterableTaskScheduler(persistenceContextProvider: persistenceContextProvider, + notificationCenter: notificationCenter, + healthMonitor: healthMonitor) + let _ = try scheduleSampleTask(scheduler: scheduler, authToken: expiredToken) + + wait(for: [retryExpectation], timeout: 5.0) + notificationCenter.removeCallbacks(withIds: retryReference.callbackId) + XCTAssertEqual(try persistenceContextProvider.mainQueueContext().findAllTasks().count, 1) + + let successExpectation = expectation(description: "task succeeds with refreshed token") + let successReference = notificationCenter.addCallback(forNotification: .iterableTaskFinishedWithSuccess) { _ in + successExpectation.fulfill() + } + authManager.setNewToken(freshToken) + networkSession.responseCallback = nil + notificationCenter.post(name: .iterableAuthTokenRefreshed, object: nil, userInfo: nil) + + wait(for: [successExpectation], timeout: 10.0) + notificationCenter.removeCallbacks(withIds: successReference.callbackId) + XCTAssertEqual(authorizationHeaders, ["Bearer \(expiredToken)", "Bearer \(freshToken)"]) + waitForZeroTasks() + + taskRunner.stop() + } + func testRetainMultipleTasksOn401AndResumeAfterAuthRefresh() throws { + let expiredToken = "expired-token" + let freshToken = "fresh-token" let jwtErrorData = ["code": "InvalidJwtPayload"].toJsonData() let networkSession = MockNetworkSession(statusCode: 401, data: jwtErrorData) + var authorizationHeaders = [String?]() + networkSession.requestCallback = { request in + authorizationHeaders.append(request.value(forHTTPHeaderField: JsonKey.Header.authorization)) + } let notificationCenter = MockNotificationCenter() @@ -540,12 +602,15 @@ class TaskRunnerTests: XCTestCase { } XCTAssertNotNil(reference) + let authManager = MockAuthManager() + authManager.token = expiredToken let taskRunner = IterableTaskRunner(networkSession: networkSession, persistenceContextProvider: persistenceContextProvider, healthMonitor: healthMonitor, notificationCenter: notificationCenter, timeInterval: 0.5, - autoRetry: true) + autoRetry: true, + authManager: authManager) taskRunner.start() // Wait for the first 401 to pause the runner @@ -557,10 +622,6 @@ class TaskRunnerTests: XCTestCase { // Remove the retry callback before resuming notificationCenter.removeCallbacks(withIds: reference.callbackId) - // Fix network and resume via auth token refresh - networkSession.responseCallback = nil - notificationCenter.post(name: .iterableAuthTokenRefreshed, object: nil, userInfo: nil) - // All 3 tasks should now process successfully let successExpectation = expectation(description: "all tasks processed") successExpectation.expectedFulfillmentCount = 3 @@ -569,7 +630,15 @@ class TaskRunnerTests: XCTestCase { } XCTAssertNotNil(successRef) + authManager.setNewToken(freshToken) + networkSession.responseCallback = nil + notificationCenter.post(name: .iterableAuthTokenRefreshed, object: nil, userInfo: nil) + wait(for: [successExpectation], timeout: 15.0) + XCTAssertEqual(authorizationHeaders, ["Bearer \(expiredToken)", + "Bearer \(freshToken)", + "Bearer \(freshToken)", + "Bearer \(freshToken)"]) waitForZeroTasks() taskRunner.stop() @@ -1468,7 +1537,8 @@ class TaskRunnerTests: XCTestCase { } } - private func scheduleSampleTask(scheduler: IterableTaskScheduler) throws -> Pending { + private func scheduleSampleTask(scheduler: IterableTaskScheduler, + authToken: String? = nil) throws -> Pending { let apiKey = "zee-api-key" let eventName = "CustomEvent1" let dataFields = ["var1": "val1", "var2": "val2"] @@ -1480,7 +1550,7 @@ class TaskRunnerTests: XCTestCase { let apiCallRequest = IterableAPICallRequest(apiKey: apiKey, endpoint: Endpoint.api, - authToken: auth.authToken, + authToken: authToken, deviceMetadata: deviceMetadata, iterableRequest: trackEventRequest) return scheduler.schedule(apiCallRequest: apiCallRequest)