diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a641076e0..9fc71c326 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ D9F1B3E5A7C2495FC1E6B8D3 /* ExecutorRequestAgingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5A1D7F2B9461EB8D4A6C2 /* ExecutorRequestAgingTests.swift */; }; 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; + B5481A0100000000000000AC /* OneSignalUserManagerImpl+Retry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5481A0100000000000000AB /* OneSignalUserManagerImpl+Retry.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; @@ -122,6 +123,8 @@ 3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */; }; 3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */; }; 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */; }; + B5481A0100000000000000A8 /* OSRequestRetryBackoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5481A0100000000000000A7 /* OSRequestRetryBackoffTests.swift */; }; + B5481A0100000000000000A6 /* OSOperationRetryTriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5481A0100000000000000A5 /* OSOperationRetryTriggerTests.swift */; }; 3C4319092F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */; }; 3C44673E296D099D0039A49E /* OneSignalMobileProvision.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411FD1E73342200E41FD7 /* OneSignalMobileProvision.m */; }; 3C44673F296D09CC0039A49E /* OneSignalMobileProvision.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411FC1E73342200E41FD7 /* OneSignalMobileProvision.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -260,8 +263,11 @@ 3CEE93582B7C78FE008440BD /* OneSignalCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */; }; 354E0C59BA9B18437C36215B /* UserExecutorRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */; }; + B5481A0100000000000000AA /* ExecutorRetryBackoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5481A0100000000000000A9 /* ExecutorRetryBackoffTests.swift */; }; 3CF11E402C6E6DE2002856F5 /* MockNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */; }; 3CF1A5632C669EA40056B3AA /* OSNewRecordsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */; }; + B5481A0100000000000000A4 /* OSRequestRetryBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5481A0100000000000000A3 /* OSRequestRetryBackoff.swift */; }; + B5481A0100000000000000A2 /* OSOperationRetryTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5481A0100000000000000A1 /* OSOperationRetryTrigger.swift */; }; 3CF8629E28A183F900776CA4 /* OSIdentityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */; }; 3CF862A028A1964F00776CA4 /* OSPropertiesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */; }; 3CF862A228A197D200776CA4 /* OSPropertiesModelStoreListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF862A128A197D200776CA4 /* OSPropertiesModelStoreListener.swift */; }; @@ -1440,6 +1446,8 @@ 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = ""; }; 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = ""; }; 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = ""; }; + B5481A0100000000000000A7 /* OSRequestRetryBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestRetryBackoffTests.swift; sourceTree = ""; }; + B5481A0100000000000000A5 /* OSOperationRetryTriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRetryTriggerTests.swift; sourceTree = ""; }; 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEndOutcomesRequestTests.swift; sourceTree = ""; }; 3C448B9B2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSBackgroundTaskHandlerImpl.h; sourceTree = ""; }; 3C448B9C2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSBackgroundTaskHandlerImpl.m; sourceTree = ""; }; @@ -1537,8 +1545,11 @@ 3CEE90A82C000BD500B0FB5B /* OneSignalRequest+UnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OneSignalRequest+UnitTests.swift"; sourceTree = ""; }; 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserExecutorTests.swift; sourceTree = ""; }; 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserExecutorRetryTests.swift; sourceTree = ""; }; + B5481A0100000000000000A9 /* ExecutorRetryBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorRetryBackoffTests.swift; sourceTree = ""; }; 3CF11E3F2C6E6DE2002856F5 /* MockNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNewRecordsState.swift; sourceTree = ""; }; 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSNewRecordsState.swift; sourceTree = ""; }; + B5481A0100000000000000A3 /* OSRequestRetryBackoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestRetryBackoff.swift; sourceTree = ""; }; + B5481A0100000000000000A1 /* OSOperationRetryTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRetryTrigger.swift; sourceTree = ""; }; 3CF8629D28A183F900776CA4 /* OSIdentityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModel.swift; sourceTree = ""; }; 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSPropertiesModel.swift; sourceTree = ""; }; 3CF862A128A197D200776CA4 /* OSPropertiesModelStoreListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSPropertiesModelStoreListener.swift; sourceTree = ""; }; @@ -1600,6 +1611,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + B5481A0100000000000000AB /* OneSignalUserManagerImpl+Retry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Retry.swift"; sourceTree = ""; }; 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoTestSupport.swift; sourceTree = ""; }; 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; @@ -2399,6 +2411,8 @@ 3C115186289ADE7700565C41 /* OSModelStoreListener.swift */, 3C115184289ADE4F00565C41 /* OSModel.swift */, 3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */, + B5481A0100000000000000A3 /* OSRequestRetryBackoff.swift */, + B5481A0100000000000000A1 /* OSOperationRetryTrigger.swift */, 3C11518A289ADEEB00565C41 /* OSEventProducer.swift */, 3C11518C289AF5E800565C41 /* OSModelChangedHandler.swift */, 3C4F9E4328A4466C009F453A /* OSOperationRepo.swift */, @@ -2582,6 +2596,7 @@ children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */, + B5481A0100000000000000A9 /* ExecutorRetryBackoffTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, @@ -2731,6 +2746,8 @@ children = ( 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, + B5481A0100000000000000A7 /* OSRequestRetryBackoffTests.swift */, + B5481A0100000000000000A5 /* OSOperationRetryTriggerTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, @@ -2968,6 +2985,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + B5481A0100000000000000AB /* OneSignalUserManagerImpl+Retry.swift */, E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, A1C4E7F2B9D3468CA5E1F0B2 /* OSRequestAging.swift */, @@ -4669,6 +4687,8 @@ 3CCC48042FCD619400D77E94 /* OSResilientStorage.swift in Sources */, 3C115185289ADE4F00565C41 /* OSModel.swift in Sources */, 3CF1A5632C669EA40056B3AA /* OSNewRecordsState.swift in Sources */, + B5481A0100000000000000A4 /* OSRequestRetryBackoff.swift in Sources */, + B5481A0100000000000000A2 /* OSOperationRetryTrigger.swift in Sources */, 3C448BA22936B474002F96BC /* OSBackgroundTaskManager.swift in Sources */, 5B58F09E2CC1B5C700298493 /* OSReadYourWriteData.swift in Sources */, 3C115187289ADE7700565C41 /* OSModelStoreListener.swift in Sources */, @@ -4747,6 +4767,7 @@ 3CB331682F281679000E1801 /* CustomEventsIntegrationTests.swift in Sources */, 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */, 354E0C59BA9B18437C36215B /* UserExecutorRetryTests.swift in Sources */, + B5481A0100000000000000AA /* ExecutorRetryBackoffTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */, @@ -4844,6 +4865,8 @@ 3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */, 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, + B5481A0100000000000000A8 /* OSRequestRetryBackoffTests.swift in Sources */, + B5481A0100000000000000A6 /* OSOperationRetryTriggerTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, 5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, @@ -4987,6 +5010,7 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + B5481A0100000000000000AC /* OneSignalUserManagerImpl+Retry.swift in Sources */, DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, B7D2A9C4E6F1483DA2C5E9F1 /* OSRequestAging.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index c1274ef84..810a2eef0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -56,6 +56,7 @@ public class OSOperationRepo: NSObject { // TODO: This could come from a config, plist, method, remote params var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS) public var paused = false + private var pollHandler: (() -> Void)? // Uncache in init so an enqueue before start cannot persist over a previous session's queue. public init(identityVerificationService: OSIdentityVerificationService) { @@ -149,8 +150,16 @@ public class OSOperationRepo: NSObject { } } + /// Runs on every poll, even while paused, for work outside the Delta queue that waits on a backoff. + public func onEveryPoll(_ handler: @escaping () -> Void) { + dispatchQueue.async { + self.pollHandler = handler + } + } + private func pollFlushQueue() { self.dispatchQueue.asyncAfter(deadline: .now() + .milliseconds(pollIntervalMilliseconds)) { [weak self] in + self?.pollHandler?() self?.flushDeltaQueue() self?.pollFlushQueue() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift new file mode 100644 index 000000000..4ec902849 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift @@ -0,0 +1,100 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ +import Foundation +import Network + +protocol OSNetworkPathMonitoring: AnyObject { + /// Calls `onAvailable` each time the network becomes available after being unavailable. + func start(onAvailable: @escaping () -> Void) +} + +final class OSNetworkPathMonitor: OSNetworkPathMonitoring { + private let monitor = NWPathMonitor() + + func start(onAvailable: @escaping () -> Void) { + var wasSatisfied: Bool? + monitor.pathUpdateHandler = { path in + let isSatisfied = path.status == .satisfied + defer { wasSatisfied = isSatisfied } + if isSatisfied, wasSatisfied == false { + onAvailable() + } + } + monitor.start(queue: DispatchQueue(label: "OneSignal.OSNetworkPathMonitor")) + } + + deinit { + monitor.cancel() + } +} + +/** + Wakes failed operations early: calls `onRetry` when the app comes to the foreground, and when a + network becomes available while it is in the foreground. Network changes in the background are + ignored, since a suspended app could not finish the attempt. + */ +public final class OSOperationRetryTrigger { + private let pathMonitor: OSNetworkPathMonitoring + /// Set once by the owner, before the trigger is handed out. + public var onRetry: () -> Void = {} + private let lock = NSLock() + private var isForeground = false + private var isMonitoring = false + + public convenience init() { + self.init(pathMonitor: OSNetworkPathMonitor()) + } + + init(pathMonitor: OSNetworkPathMonitoring) { + self.pathMonitor = pathMonitor + } + + public func onFocus() { + let shouldStartMonitoring = lock.withLock { + isForeground = true + defer { isMonitoring = true } + return !isMonitoring + } + if shouldStartMonitoring { + pathMonitor.start { [weak self] in + self?.onNetworkAvailable() + } + } + onRetry() + } + + public func onUnfocus() { + lock.withLock { isForeground = false } + } + + private func onNetworkAvailable() { + guard lock.withLock({ isForeground }) else { + return + } + onRetry() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift new file mode 100644 index 000000000..3be5878ee --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift @@ -0,0 +1,142 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +/** + Tracks when a Request that failed with a retryable error may be sent again in this run. Delays grow + exponentially with jitter, and a server's `Retry-After` is the minimum wait. + + State lives in memory, keyed weakly by Request, so it ends with the Request. After a relaunch, cached + Requests are sent right away. + */ +public final class OSRequestRetryBackoff { + static let baseDelaySeconds: TimeInterval = 5 + static let maxDelaySeconds: TimeInterval = 300 + static let defaultRetryAfterSeconds: TimeInterval = 60 + static let maxRetryAfterSeconds: TimeInterval = 3600 + + private final class Entry { + var failures = 0 + var retryAt: TimeInterval = 0 + var retryAfterAt: TimeInterval = 0 + } + + private let lock = NSLock() + private let entries = NSMapTable.weakToStrongObjects() + private let uptime: () -> TimeInterval + private let jitter: () -> Double + + public convenience init() { + // CLOCK_MONOTONIC keeps counting while the device sleeps, unlike `systemUptime`. + self.init(uptime: { TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC)) / 1e9 }, jitter: { Double.random(in: 0.5...1) }) + } + + init(uptime: @escaping () -> TimeInterval, jitter: @escaping () -> Double) { + self.uptime = uptime + self.jitter = jitter + } + + public func isWaiting(_ request: AnyObject) -> Bool { + lock.withLock { + (entries.object(forKey: request)?.retryAt ?? 0) > uptime() + } + } + + func isWaitingOnAny() -> Bool { + lock.withLock { + let now = uptime() + let enumerator = entries.objectEnumerator() + while let entry = enumerator?.nextObject() as? Entry { + if entry.retryAt > now { + return true + } + } + return false + } + } + + /// Returns the seconds until `request` may be sent again. + @discardableResult + public func recordFailure(_ request: AnyObject, error: OneSignalClientError) -> TimeInterval { + let retryAfter = Self.retryAfterSeconds(error) + return lock.withLock { + let entry = entries.object(forKey: request) ?? Entry() + entries.setObject(entry, forKey: request) + entry.failures += 1 + let exponential = Self.baseDelaySeconds * pow(2, Double(entry.failures - 1)) + let now = uptime() + entry.retryAfterAt = retryAfter.map { now + $0 } ?? 0 + entry.retryAt = max(now + min(exponential, Self.maxDelaySeconds) * jitter(), entry.retryAfterAt) + return entry.retryAt - now + } + } + + /// Moves `old`'s wait onto `new`, so replacing a Request that is backing off does not skip its wait. + public func transferWait(from old: AnyObject, to new: AnyObject) { + lock.withLock { + if let entry = entries.object(forKey: old) { + entries.setObject(entry, forKey: new) + } + } + } + + /// Ends every backoff early, except what a server's `Retry-After` asked for. Returns whether any + /// Request can now be sent sooner. + @discardableResult + public func retryNow() -> Bool { + lock.withLock { + let now = uptime() + var endedAWait = false + let enumerator = entries.objectEnumerator() + while let entry = enumerator?.nextObject() as? Entry { + if entry.retryAt > max(now, entry.retryAfterAt) { + entry.retryAt = entry.retryAfterAt + endedAWait = true + } + } + return endedAWait + } + } + + /// Only the delay-seconds form is supported, not the HTTP-date form. Capped, so a bad header cannot + /// hold a queue for the rest of the run. + static func retryAfterSeconds(_ error: OneSignalClientError) -> TimeInterval? { + let value = error.responseHeaders?.first { key, _ in + (key as? String)?.caseInsensitiveCompare("Retry-After") == .orderedSame + }?.value + if let value { + guard let seconds = TimeInterval("\(value)".trimmingCharacters(in: .whitespaces)), + seconds.isFinite, seconds >= 0 else { + return defaultRetryAfterSeconds + } + return min(seconds, maxRetryAfterSeconds) + } + return error.code == 429 ? defaultRetryAfterSeconds : nil + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift new file mode 100644 index 000000000..0f57e928e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift @@ -0,0 +1,89 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ +import XCTest +@testable import OneSignalOSCore + +private final class FakePathMonitor: OSNetworkPathMonitoring { + private(set) var startCount = 0 + private var onAvailable: (() -> Void)? + + func start(onAvailable: @escaping () -> Void) { + startCount += 1 + self.onAvailable = onAvailable + } + + func networkBecomesAvailable() { + onAvailable?() + } +} + +final class OSOperationRetryTriggerTests: XCTestCase { + private var monitor: FakePathMonitor! + private var trigger: OSOperationRetryTrigger! + private var retryCount = 0 + + override func setUp() { + super.setUp() + monitor = FakePathMonitor() + trigger = OSOperationRetryTrigger(pathMonitor: monitor) + retryCount = 0 + trigger.onRetry = { [unowned self] in self.retryCount += 1 } + } + + func testRetriesOnFocus() { + trigger.onFocus() + + XCTAssertEqual(retryCount, 1) + } + + func testRetriesWhenTheNetworkReturnsWhileFocused() { + trigger.onFocus() + + monitor.networkBecomesAvailable() + + XCTAssertEqual(retryCount, 2) + } + + func testIgnoresTheNetworkReturningInTheBackground() { + trigger.onFocus() + trigger.onUnfocus() + + monitor.networkBecomesAvailable() + + XCTAssertEqual(retryCount, 1) + } + + func testStartsMonitoringOnceOnTheFirstFocus() { + XCTAssertEqual(monitor.startCount, 0) + + trigger.onFocus() + trigger.onUnfocus() + trigger.onFocus() + + XCTAssertEqual(monitor.startCount, 1) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift new file mode 100644 index 000000000..3940b4822 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift @@ -0,0 +1,139 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSRequestRetryBackoffTests: XCTestCase { + private var uptime: TimeInterval = 1_000 + private var backoff: OSRequestRetryBackoff! + + override func setUp() { + super.setUp() + uptime = 1_000 + backoff = OSRequestRetryBackoff(uptime: { self.uptime }, jitter: { 1 }) + } + + private func error(_ code: Int, headers: [String: String]? = nil) -> OneSignalClientError { + return OneSignalClientError(code: code, message: "not-important", responseHeaders: headers, response: nil, underlyingError: nil) + } + + func testDelaysDoubleUpToTheCap() { + let request = NSObject() + + let delays = (0..<8).map { _ in backoff.recordFailure(request, error: error(500)) } + + XCTAssertEqual(delays, [5, 10, 20, 40, 80, 160, 300, 300]) + } + + func testJitterShortensTheDelay() { + backoff = OSRequestRetryBackoff(uptime: { self.uptime }, jitter: { 0.5 }) + + XCTAssertEqual(backoff.recordFailure(NSObject(), error: error(500)), 2.5) + } + + func testWaitsUntilTheDelayPasses() { + let request = NSObject() + XCTAssertFalse(backoff.isWaiting(request)) + + backoff.recordFailure(request, error: error(0)) + XCTAssertTrue(backoff.isWaiting(request)) + + uptime += 5 + XCTAssertFalse(backoff.isWaiting(request)) + } + + func testRequestsBackOffIndependently() { + let failed = NSObject() + backoff.recordFailure(failed, error: error(500)) + + XCTAssertFalse(backoff.isWaiting(NSObject())) + } + + func testAReplacementKeepsTheWaitOfTheRequestItReplaces() { + let failed = NSObject() + let replacement = NSObject() + backoff.recordFailure(failed, error: error(500)) + + backoff.transferWait(from: failed, to: replacement) + XCTAssertTrue(backoff.isWaiting(replacement)) + + uptime += 5 + XCTAssertFalse(backoff.isWaiting(replacement)) + } + + func testRetryAfterIsTheMinimumWait() { + XCTAssertEqual(backoff.recordFailure(NSObject(), error: error(503, headers: ["retry-after": "120"])), 120) + } + + func testA429WithoutRetryAfterWaitsTheDefault() { + XCTAssertEqual(backoff.recordFailure(NSObject(), error: error(429)), OSRequestRetryBackoff.defaultRetryAfterSeconds) + } + + func testRetryAfterThatIsNotAFiniteDelayWaitsTheDefault() { + for value in ["inf", "nan", "-5", "Wed, 21 Oct 2026 07:28:00 GMT"] { + let delay = backoff.recordFailure(NSObject(), error: error(503, headers: ["Retry-After": value])) + XCTAssertEqual(delay, OSRequestRetryBackoff.defaultRetryAfterSeconds, value) + } + } + + func testRetryAfterIsCapped() { + let delay = backoff.recordFailure(NSObject(), error: error(503, headers: ["Retry-After": "1e400"])) + XCTAssertEqual(delay, OSRequestRetryBackoff.defaultRetryAfterSeconds, "1e400 overflows to inf") + + let capped = backoff.recordFailure(NSObject(), error: error(503, headers: ["Retry-After": "86400"])) + XCTAssertEqual(capped, OSRequestRetryBackoff.maxRetryAfterSeconds) + } + + func testRetryNowReportsWhetherItEndedAWait() { + XCTAssertFalse(backoff.retryNow(), "nothing is waiting") + + let request = NSObject() + backoff.recordFailure(request, error: error(500)) + XCTAssertTrue(backoff.retryNow()) + XCTAssertFalse(backoff.retryNow(), "already ended") + + backoff.recordFailure(request, error: error(500)) + uptime += 60 + XCTAssertFalse(backoff.retryNow(), "the wait had already passed") + } + + func testRetryNowEndsTheBackoffButNotRetryAfter() { + let backingOff = NSObject() + let rateLimited = NSObject() + backoff.recordFailure(backingOff, error: error(500)) + backoff.recordFailure(rateLimited, error: error(429, headers: ["Retry-After": "30"])) + + backoff.retryNow() + + XCTAssertFalse(backoff.isWaiting(backingOff)) + XCTAssertTrue(backoff.isWaiting(rateLimited)) + uptime += 30 + XCTAssertFalse(backoff.isWaiting(rateLimited)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index 57162d51d..185ded8a3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -50,14 +50,21 @@ class OSCustomEventsExecutor: OSOperationExecutor { private var requestQueue: [OSRequestCustomEvents] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff private let nowProvider: () -> Date // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSCustomEventsExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { + init( + newRecordsState: OSNewRecordsState, + auth: OSRequestAuthorizing, + retryBackoff: OSRequestRetryBackoff = OSRequestRetryBackoff(), + nowProvider: @escaping () -> Date = { Date() } + ) { self.newRecordsState = newRecordsState self.auth = auth + self.retryBackoff = retryBackoff self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... uncacheDeltas() @@ -323,7 +330,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { } private func executeRequest(_ request: OSRequestCustomEvents, inBackground: Bool) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { @@ -354,6 +361,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { // Fail, no retry, remove from cache and queue self.requestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + } else { + self.retryBackoff.recordFailure(request, error: error) + request.sentToClient = false } // TODO: Handle payload too large (not necessary for alpha release) if inBackground { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index ecf130098..4c85052b7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -36,14 +36,21 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private var removeRequestQueue: [OSRequestRemoveAlias] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff private let nowProvider: () -> Date // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { + init( + newRecordsState: OSNewRecordsState, + auth: OSRequestAuthorizing, + retryBackoff: OSRequestRetryBackoff = OSRequestRetryBackoff(), + nowProvider: @escaping () -> Date = { Date() } + ) { self.newRecordsState = newRecordsState self.auth = auth + self.retryBackoff = retryBackoff self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... uncacheDeltas() @@ -240,7 +247,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } func executeAddAliasesRequest(_ request: OSRequestAddAliases, inBackground: Bool) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { @@ -290,6 +297,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } else { + self.retryBackoff.recordFailure(request, error: error) + request.sentToClient = false } if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) @@ -299,7 +309,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } func executeRemoveAliasRequest(_ request: OSRequestRemoveAlias, inBackground: Bool) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { @@ -335,6 +345,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // A response of .missing could mean the alias doesn't exist on this user OR this user has been deleted self.removeRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } else { + self.retryBackoff.recordFailure(request, error: error) + request.sentToClient = false } if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 41b773f17..aef7f8676 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -71,14 +71,21 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private var updateRequestQueue: [OSRequestUpdateProperties] = [] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff private let nowProvider: () -> Date // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing, nowProvider: @escaping () -> Date = { Date() }) { + init( + newRecordsState: OSNewRecordsState, + auth: OSRequestAuthorizing, + retryBackoff: OSRequestRetryBackoff = OSRequestRetryBackoff(), + nowProvider: @escaping () -> Date = { Date() } + ) { self.newRecordsState = newRecordsState self.auth = auth + self.retryBackoff = retryBackoff self.nowProvider = nowProvider // Read unfinished deltas and requests from cache, if any... // Note that we should only have deltas for the current user as old ones are flushed.. @@ -277,7 +284,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } func executeUpdatePropertiesRequest(_ request: OSRequestUpdateProperties, inBackground: Bool) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { @@ -343,6 +350,9 @@ class OSPropertyOperationExecutor: OSOperationExecutor { // Fail, no retry, remove from cache and queue self.updateRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + } else { + self.retryBackoff.recordFailure(request, error: error) + request.sentToClient = false } if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 9b4c805f7..538859c83 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -38,13 +38,19 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private var subscriptionModels: [String: OSSubscriptionModel] = [:] private let newRecordsState: OSNewRecordsState private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff // The Subscription executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSubscriptionOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { + init( + newRecordsState: OSNewRecordsState, + auth: OSRequestAuthorizing, + retryBackoff: OSRequestRetryBackoff = OSRequestRetryBackoff() + ) { self.newRecordsState = newRecordsState self.auth = auth + self.retryBackoff = retryBackoff // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheCreateSubscriptionRequests() @@ -271,10 +277,14 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { case OS_UPDATE_SUBSCRIPTION_DELTA: // Keep at most one unsent UpdateSubscription per subscription (last wins). let modelId = subModel.modelId - self.updateRequestQueue.removeAll { request in + let isUnsent = { (request: OSRequestUpdateSubscription) in !request.sentToClient && request.subscriptionModel.modelId == modelId } + let replaced = self.updateRequestQueue.last(where: isUnsent) + self.updateRequestQueue.removeAll(where: isUnsent) let request = OSRequestUpdateSubscription(subscriptionModel: subModel, identityModel: identityModel) + // Carry a backoff over, or the replacement would skip it. + replaced.map { self.retryBackoff.transferWait(from: $0, to: request) } self.updateRequestQueue.append(request) default: @@ -337,7 +347,7 @@ extension OSSubscriptionOperationExecutor { } private func executeCreateSubscriptionRequest(_ request: OSRequestCreateSubscription, inBackground: Bool) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { @@ -402,6 +412,9 @@ extension OSSubscriptionOperationExecutor { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } else { + self.retryBackoff.recordFailure(request, error: error) + request.sentToClient = false } if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) @@ -411,7 +424,7 @@ extension OSSubscriptionOperationExecutor { } func executeDeleteSubscriptionRequest(_ request: OSRequestDeleteSubscription, inBackground: Bool) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { @@ -446,6 +459,9 @@ extension OSSubscriptionOperationExecutor { // If this request returns a missing status, that is ok as this is a delete request self.removeRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } else { + self.retryBackoff.recordFailure(request, error: error) + request.sentToClient = false } if inBackground { OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier) @@ -455,7 +471,7 @@ extension OSSubscriptionOperationExecutor { } private func executeUpdateSubscriptionRequest(_ request: OSRequestUpdateSubscription, inBackground: Bool) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } // Single-flight: don't send a second UpdateSubscription for the same model while one is in flight. @@ -506,7 +522,7 @@ extension OSSubscriptionOperationExecutor { OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) self.executeNextPendingUpdateSubscription(for: modelId, inBackground: inBackground) } else { - // Make the request eligible for the next flush + self.retryBackoff.recordFailure(request, error: error) request.sentToClient = false } if inBackground { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index da751dd62..d10d7c53c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -38,16 +38,29 @@ class OSUserExecutor { private let newRecordsState: OSNewRecordsState private let identityVerificationService: OSIdentityVerificationService private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff + /// Only touched on `dispatchQueue`. + private var awaitingRetry = false + /// A Create User the server rejected, held until a new session. Only touched on `dispatchQueue`. + private var rejectedCreateUsers: [OSRequestCreateUser] = [] + /// Only touched on `dispatchQueue`. + private var sessionCount = 0 /// Delay by the "cool down" period plus a buffer of a set amount of milliseconds private let flushDelayMilliseconds = Int(OP_REPO_POST_CREATE_DELAY_SECONDS * 1_000 + 200) // TODO: This could come from a config, plist, method, remote params /// The User executor dispatch queue, serial. This synchronizes access to the request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSUserExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState, identityVerificationService: OSIdentityVerificationService, auth: OSRequestAuthorizing) { + init( + newRecordsState: OSNewRecordsState, + identityVerificationService: OSIdentityVerificationService, + auth: OSRequestAuthorizing, + retryBackoff: OSRequestRetryBackoff = OSRequestRetryBackoff() + ) { self.newRecordsState = newRecordsState self.identityVerificationService = identityVerificationService self.auth = auth + self.retryBackoff = retryBackoff uncacheUserRequests() migrateTransferSubscriptionRequests() createUserIfNeverCreated(OneSignalUserManagerImpl.sharedInstance._user) @@ -326,7 +339,59 @@ class OSUserExecutor { } } + /// Backs off, then `retryIfDue` re-sends. Requests behind this one keep waiting, so order is preserved. + private func retryLater(_ request: OSUserRequest, error: OneSignalClientError) { + retryBackoff.recordFailure(request, error: error) + dispatchQueue.async { + request.sentToClient = false + self.awaitingRetry = true + } + } + + /// Called on the Operation Repo's poll rather than a timer of its own: the poll re-reads the backoff + /// clock, which keeps counting while the device sleeps, so a wait that ended during sleep is not stranded. + func retryIfDue() { + dispatchQueue.async { + guard self.awaitingRetry, + !self.userRequestQueue.contains(where: { self.retryBackoff.isWaiting($0) }) else { + return + } + self.awaitingRetry = false + self._executePendingRequests() + } + } + + func executePendingRequestsOnNewSession() { + dispatchQueue.async { + self.sessionCount += 1 + self.dropRejectedCreateUsersOfPastUsers() + for request in self.rejectedCreateUsers { + request.sentToClient = false + } + self.rejectedCreateUsers.removeAll() + self._executePendingRequests() + } + } + + /// A logged-out user's rejected Create User would otherwise hold the next user's Requests behind it. + private func dropRejectedCreateUsersOfPastUsers() { + let pastUsers = rejectedCreateUsers.filter { + OneSignalUserManagerImpl.sharedInstance.currentUser(matching: $0.identityModel.modelId) == nil + } + guard !pastUsers.isEmpty else { + return + } + let isPastUser = { (request: OSUserRequest) in pastUsers.contains { $0 === request } } + rejectedCreateUsers.removeAll(where: isPastUser) + userRequestQueue.removeAll(where: isPastUser) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: userRequestQueue) + if rejectedCreateUsers.isEmpty { + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + } + } + private func _executePendingRequests() { + dropRejectedCreateUsersOfPastUsers() // Hold until known: a Create User sent now would go out unsigned if `requirement` later becomes on. guard identityVerificationService.requirement != .unknown else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(self.userRequestQueue.count) Requests until the Identity Verification requirement is known") @@ -404,7 +469,7 @@ extension OSUserExecutor { } func executeCreateUserRequest(_ request: OSRequestCreateUser) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } @@ -431,6 +496,7 @@ extension OSUserExecutor { } request.sentToClient = true + let sentInSession = sessionCount OneSignalCoreImpl.sharedClient().execute(request) { response in // Create User's response won't send us the user's complete info if this user already exists @@ -482,9 +548,22 @@ extension OSUserExecutor { } else if responseType != .retryable { // A failed create user request would leave the SDK in a bad state // Don't remove the request from cache and pause the operation repo - // We will retry this request on a new session + // We will retry this request on a new session. Stays marked as sent until then, so other + // flushes in this run skip it. OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true - request.sentToClient = false + self.dispatchQueue.async { + if self.sessionCount != sentInSession { + // A session started while this was in flight, so this is that session's retry. + request.sentToClient = false + self._executePendingRequests() + } else { + self.rejectedCreateUsers.append(request) + // A logout or login while this was in flight stopped on it; re-enter so it is dropped. + self._executePendingRequests() + } + } + } else { + self.retryLater(request, error: error) } } } @@ -500,7 +579,7 @@ extension OSUserExecutor { For migrating legacy players from 3.x to 5.x. This request will fetch the identity object for a subscription ID, and we will use the returned onesignalId to fetch and hydrate the local user. */ func executeFetchIdentityBySubscriptionRequest(_ request: OSRequestFetchIdentityBySubscription) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } @@ -538,6 +617,8 @@ extension OSUserExecutor { // Fail, no retry, remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil self.removeFromQueue(request) + } else { + self.retryLater(request, error: error) } self.executePendingRequests() } @@ -557,7 +638,7 @@ extension OSUserExecutor { } func executeIdentifyUserRequest(_ request: OSRequestIdentifyUser) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } @@ -624,6 +705,8 @@ extension OSUserExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .retryable { + self.retryLater(request, error: error) } } } @@ -638,7 +721,7 @@ extension OSUserExecutor { } func executeFetchUserRequest(_ request: OSRequestFetchUser) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } @@ -694,6 +777,8 @@ extension OSUserExecutor { } else if responseType != .retryable { // If the error is not retryable, remove from cache and queue self.removeFromQueue(request) + } else { + self.retryLater(request, error: error) } self.executePendingRequests() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift new file mode 100644 index 000000000..1c6aabab6 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift @@ -0,0 +1,51 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import OneSignalCore +import OneSignalOSCore + +extension OneSignalUserManagerImpl { + @objc + public func onFocus() { + retryTrigger.onFocus() + } + + @objc + public func onUnfocus() { + retryTrigger.onUnfocus() + } + + /// Sends failed Requests now instead of waiting out their backoff. Does nothing if none are waiting, + /// so a brief resign-active does not resend work held for other reasons. + func retryNow() { + guard retryBackoff.retryNow() else { + return + } + userExecutor?.executePendingRequests() + operationRepo.addFlushDeltaQueueToDispatchQueue() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 1e5dc6a11..1d914e1b0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -210,6 +210,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener let pushSubscriptionModelStoreListener: OSSubscriptionModelStoreListener + let retryBackoff = OSRequestRetryBackoff() + let retryTrigger = OSOperationRetryTrigger() // Executors must be initialize after sharedInstance is initialized var userExecutor: OSUserExecutor? @@ -236,6 +238,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore, operationRepo: operationRepo) self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) + super.init() + retryTrigger.onRetry = { [weak self] in + self?.retryNow() + } } @objc @@ -310,13 +316,21 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // Setup the executors // The OSUserExecutor has to run first, before other executors - self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) + self.userExecutor = OSUserExecutor( + newRecordsState: newRecordsState, + identityVerificationService: identityVerificationService, + auth: requestAuth, + retryBackoff: retryBackoff + ) + operationRepo.onEveryPoll { [weak self] in + self?.userExecutor?.retryIfDue() + } // Cannot initialize these executors in `init` as they reference the sharedInstance - let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) - let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) - let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) - let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) + let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) + let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) + let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor @@ -683,7 +697,7 @@ extension OneSignalUserManagerImpl { } start() - userExecutor!.executePendingRequests() + userExecutor!.executePendingRequestsOnNewSession() operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift new file mode 100644 index 000000000..9bbb5897e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift @@ -0,0 +1,280 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Executors retry a retryable failure in the same run, after a backoff, instead of at the next launch. +final class ExecutorRetryBackoffTests: XCTestCase { + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + private var uptime: TimeInterval = 1_000 + private var backoff: OSRequestRetryBackoff! + private var identityModel = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + private var addAliasesDescription = "" + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + client.executeInstantaneously = true + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + uptime = 1_000 + backoff = OSRequestRetryBackoff(uptime: { self.uptime }, jitter: { 1 }) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + identityModel = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID).identityModel + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private func error(_ code: Int, headers: [String: String]? = nil) -> OneSignalClientError { + return OneSignalClientError(code: code, message: "not-important", responseHeaders: headers, response: nil, underlyingError: nil) + } + + private func cachedAddAliases(failingWith failure: OneSignalClientError) -> OSIdentityOperationExecutor { + let request = OSRequestAddAliases(aliases: ["test_alias_label": "test-alias-id"], identityModel: identityModel, ownerExternalId: userA_EUID) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: [request]) + addAliasesDescription = request.description + client.setMockFailureResponseForRequest(request: addAliasesDescription, error: failure) + return OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, retryBackoff: backoff) + } + + private func addAliasesCount() -> Int { + return client.executedRequests.filter { $0 is OSRequestAddAliases }.count + } + + private func cachedAddAliases() -> [OSRequestAddAliases] { + let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] + return cached ?? [] + } + + /// Flushes, then waits until the attempt has been sent and its response handled. + private func flushAndWaitForAttempt(_ executor: OSIdentityOperationExecutor, number: Int) { + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("Attempt \(number) was not handled") { + self.addAliasesCount() == number && self.backoff.isWaitingOnAny() + } + } + + /// Flushes and gives the executor time to send, for asserting that nothing was sent. + private func flushExpectingNoAttempt(_ executor: OSIdentityOperationExecutor) { + let before = addAliasesCount() + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun(seconds: 0.3) + XCTAssertEqual(addAliasesCount(), before, "still backing off") + } + + func testARetryableFailureIsResentOnceTheBackoffPasses() { + let executor = cachedAddAliases(failingWith: error(500)) + + flushAndWaitForAttempt(executor, number: 1) + flushExpectingNoAttempt(executor) + + uptime += OSRequestRetryBackoff.baseDelaySeconds + flushAndWaitForAttempt(executor, number: 2) + XCTAssertEqual(cachedAddAliases().count, 1, "kept until it succeeds") + } + + func testASuccessfulRetryRemovesTheRequest() { + let executor = cachedAddAliases(failingWith: error(0)) + flushAndWaitForAttempt(executor, number: 1) + + client.setMockResponseForRequest(request: addAliasesDescription, response: [:]) + uptime += OSRequestRetryBackoff.baseDelaySeconds + executor.processDeltaQueue(inBackground: false) + + OneSignalCoreMocks.waitUntil("The retried Add Aliases was not removed") { + self.addAliasesCount() == 2 && self.cachedAddAliases().isEmpty + } + } + + func testANonRetryableFailureIsNotRetried() { + let executor = cachedAddAliases(failingWith: error(400)) + + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The rejected Add Aliases was not removed") { + self.addAliasesCount() == 1 && self.cachedAddAliases().isEmpty + } + uptime += OSRequestRetryBackoff.maxDelaySeconds + flushExpectingNoAttempt(executor) + } + + func testRetryNowEndsTheBackoff() { + let executor = cachedAddAliases(failingWith: error(500)) + flushAndWaitForAttempt(executor, number: 1) + + backoff.retryNow() + flushAndWaitForAttempt(executor, number: 2) + } + + func testRetryNowStillWaitsOutRetryAfter() { + let executor = cachedAddAliases(failingWith: error(429, headers: ["Retry-After": "120"])) + flushAndWaitForAttempt(executor, number: 1) + + backoff.retryNow() + uptime += 119 + flushExpectingNoAttempt(executor) + + uptime += 1 + flushAndWaitForAttempt(executor, number: 2) + } + + private func userExecutor() -> OSUserExecutor { + return OSUserExecutor( + newRecordsState: newRecordsState, + identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, + retryBackoff: backoff + ) + } + + private func count(_ type: AnyClass) -> Int { + return client.executedRequests.filter { $0.isKind(of: type) }.count + } + + /// The User executor re-sends from the Operation Repo's poll, and holds the Requests behind it. + func testTheUserExecutorRetriesAFailedFetchUserOnThePollOnceTheBackoffPasses() { + let fetch = "" + let executor = userExecutor() + client.setMockFailureResponseForRequest(request: fetch, error: error(500)) + + executor.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: userA_OSID, identityModel: identityModel, onNewSession: true) + OneSignalCoreMocks.waitUntil("The Fetch User was not attempted") { + self.count(OSRequestFetchUser.self) == 1 && self.backoff.isWaitingOnAny() + } + + // Includes this device's subscription, so the on-new-session check does not go on to create one. + let response: [String: Any] = [ + "identity": [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], + "subscriptions": [["id": testPushSubId, "type": "iOSPush"]] + ] + client.setMockResponseForRequest(request: fetch, response: response) + executor.retryIfDue() + allowAsyncWorkToRun(seconds: 0.3) + XCTAssertEqual(count(OSRequestFetchUser.self), 1, "still backing off") + + uptime += OSRequestRetryBackoff.baseDelaySeconds + OneSignalCoreMocks.waitUntil("The Fetch User was not retried until it succeeded") { + executor.retryIfDue() + return self.count(OSRequestFetchUser.self) == 2 && executor.userRequestQueue.isEmpty + } + allowAsyncWorkToRun() + } + + /// Holds the Create User's response, then rejects it with a 400 once `whileInFlight` has run. + private func createUserRejected( + _ executor: OSUserExecutor, + for model: OSIdentityModel, + externalId: String, + whileInFlight: () -> Void = {} + ) { + client.holdResponses = true + executor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: externalId, identityModel: model) + OneSignalCoreMocks.waitUntil("The Create User was not attempted") { + self.client.startedRequests.contains { $0 is OSRequestCreateUser } + } + let createUser = client.startedRequests.first { $0 is OSRequestCreateUser }! + client.setMockFailureResponseForRequest(request: createUser.description, error: error(400)) + whileInFlight() + client.releaseHeldResponses() + } + + func testARejectedCreateUserIsOnlyResentOnANewSession() { + let executor = userExecutor() + createUserRejected(executor, for: identityModel, externalId: userA_EUID) + + // A foreground that ends some other backoff flushes the executor. + executor.executePendingRequests() + executor.retryIfDue() + allowAsyncWorkToRun(seconds: 0.3) + XCTAssertEqual(count(OSRequestCreateUser.self), 1, "held until a new session") + + executor.executePendingRequestsOnNewSession() + OneSignalCoreMocks.waitUntil("The Create User was not resent on the new session") { + self.count(OSRequestCreateUser.self) == 2 + } + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + } + + func testARejectedCreateUserOfALoggedOutUserDoesNotHoldTheNextUser() { + let executor = userExecutor() + let loggedOut = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + createUserRejected(executor, for: loggedOut, externalId: userB_EUID) + OneSignalCoreMocks.waitUntil("The repo was not paused") { + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused + } + + client.setMockResponseForRequest(request: "", response: [:]) + executor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: identityModel) + OneSignalCoreMocks.waitUntil("The current user's Create User was held behind the logged out one") { + self.count(OSRequestCreateUser.self) == 2 + } + XCTAssertFalse(executor.userRequestQueue.contains { ($0 as? OSRequestCreateUser)?.identityModel === loggedOut }) + XCTAssertFalse(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + } + + func testALoginWhileTheRejectedCreateUserIsInFlightIsNotHeldBehindIt() { + let executor = userExecutor() + let loggedOut = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + createUserRejected(executor, for: loggedOut, externalId: userB_EUID) { + client.setMockResponseForRequest(request: "", response: [:]) + executor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: identityModel) + allowAsyncWorkToRun(seconds: 0.3) + } + + OneSignalCoreMocks.waitUntil("The current user's Create User was held behind the in-flight logged out one") { + self.count(OSRequestCreateUser.self) == 2 + } + OneSignalCoreMocks.waitUntil("The repo was left paused") { + !OneSignalUserManagerImpl.sharedInstance.operationRepo.paused + } + } + + func testACreateUserRejectedAfterANewSessionStartedIsResentRightAway() { + let executor = userExecutor() + createUserRejected(executor, for: identityModel, externalId: userA_EUID) { + executor.executePendingRequestsOnNewSession() + allowAsyncWorkToRun(seconds: 0.3) + } + + OneSignalCoreMocks.waitUntil("The Create User was not resent for the session that started while it was in flight") { + self.count(OSRequestCreateUser.self) == 2 + } + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index 887eb083f..a90dbf913 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -214,14 +214,19 @@ final class SubscriptionUpdateRaceTests: XCTestCase { /** A retryable failure (e.g. 500/timeout) must not leave the single-flight gate locked. - The failed request becomes resendable and later updates for the model still go out. + The failed request becomes resendable and a later update for the model replaces it, keeping its backoff. */ func testRetryableFailureDoesNotBlockSubsequentUpdates() throws { let client = MockOneSignalClient() client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + var uptime: TimeInterval = 1_000 + let executor = OSSubscriptionOperationExecutor( + newRecordsState: OSNewRecordsState(), + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, + retryBackoff: OSRequestRetryBackoff(uptime: { uptime }, jitter: { 1 }) + ) let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) let identityModelId = UUID().uuidString @@ -261,6 +266,11 @@ final class SubscriptionUpdateRaceTests: XCTestCase { value: subscribedNotificationTypes )) executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun(seconds: 0.3) + XCTAssertEqual(client.executedRequests.count, 1, "The replacement waits out the failed request's backoff") + + uptime += OSRequestRetryBackoff.baseDelaySeconds + executor.processDeltaQueue(inBackground: false) OneSignalCoreMocks.waitUntil("Follow-up subscription update did not complete") { client.hasCompletedRequestOfType(OSRequestUpdateSubscription.self, expectedCount: 2) } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m index 19ae24fcf..afdf1631a 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalTracker.m @@ -80,6 +80,7 @@ + (void)applicationBecameActive { OSSessionManager.sharedSessionManager.appEntryState = APP_OPEN; [OSSessionManager.sharedSessionManager setLastOpenedTime:[[NSDate date] timeIntervalSince1970]]; + [OneSignalUserManagerImpl.sharedInstance onFocus]; // on_session tracking when resumming app. if ([OneSignal shouldStartNewSession]) @@ -93,6 +94,7 @@ + (void)applicationBecameActive { + (void)applicationBackgrounded { [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Application Backgrounded started"]; + [OneSignalUserManagerImpl.sharedInstance onUnfocus]; [self updateLastClosedTime]; let timeElapsed = [OSSessionManager.sharedSessionManager getTimeFocusedElapsed];