Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion swift-sdk/Internal/IterableAPICallRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions swift-sdk/Internal/IterableAPICallTaskProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<IterableTaskResult, IterableTaskError> {
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions swift-sdk/Internal/IterableTaskRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +26,7 @@ class IterableTaskRunner: NSObject {
self.connectivityManager = connectivityManager
self.persistenceContext = persistenceContextProvider.newBackgroundContext()
self.autoRetry = autoRetry
self.authManager = authManager

super.init()

Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ extension DependencyContainerProtocol {
healthMonitor: healthMonitor!),
taskRunner: createTaskRunner(persistenceContextProvider: persistenceContextProvider,
healthMonitor: healthMonitor!,
autoRetry: localStorage.autoRetry),
autoRetry: localStorage.autoRetry,
authManager: authManager),
notificationCenter: notificationCenter)


Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 27 additions & 2 deletions tests/offline-events-tests/TaskProcessorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()!
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down
84 changes: 77 additions & 7 deletions tests/offline-events-tests/TaskRunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -1468,7 +1537,8 @@ class TaskRunnerTests: XCTestCase {
}
}

private func scheduleSampleTask(scheduler: IterableTaskScheduler) throws -> Pending<String, IterableTaskError> {
private func scheduleSampleTask(scheduler: IterableTaskScheduler,
authToken: String? = nil) throws -> Pending<String, IterableTaskError> {
let apiKey = "zee-api-key"
let eventName = "CustomEvent1"
let dataFields = ["var1": "val1", "var2": "val2"]
Expand All @@ -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)
Expand Down
Loading