From 11f99c25fd728a1da6eebf0bd4c0192cbdac8913 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 12 Oct 2026 12:10:51 -0700 Subject: [PATCH 01/11] fix: [SDK-5481] retry failed operations within the same run Executors kept a request after a retryable failure but only resent it on the next launch. They now back off exponentially with jitter (5s to 300s, honoring Retry-After) and resend in the same run. Foreground and network return end the backoff early. Co-Authored-By: Claude Opus 5.5 --- .../OneSignal.xcodeproj/project.pbxproj | 20 ++ .../Source/OSOperationRetryTrigger.swift | 100 ++++++++++ .../Source/OSRequestRetryBackoff.swift | 105 ++++++++++ .../OSOperationRetryTriggerTests.swift | 89 +++++++++ .../OSRequestRetryBackoffTests.swift | 99 ++++++++++ .../Executors/OSCustomEventsExecutor.swift | 14 +- .../OSIdentityOperationExecutor.swift | 19 +- .../OSPropertyOperationExecutor.swift | 14 +- .../OSSubscriptionOperationExecutor.swift | 22 ++- .../Source/Executors/OSUserExecutor.swift | 36 +++- .../Source/OneSignalUserManagerImpl.swift | 42 +++- .../Executors/ExecutorRetryBackoffTests.swift | 180 ++++++++++++++++++ .../OneSignalSDK/Source/OneSignalTracker.m | 2 + 13 files changed, 720 insertions(+), 22 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRetryTrigger.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRetryTriggerTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a641076e0..57f6fa32d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -122,6 +122,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 +262,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 +1445,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 +1544,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 = ""; }; @@ -2399,6 +2409,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 +2594,7 @@ children = ( 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 6C310B6C569E96C204F8CD68 /* UserExecutorRetryTests.swift */, + B5481A0100000000000000A9 /* ExecutorRetryBackoffTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, C95B2AD05FFF9FC12C9D5878 /* ExecutorReadYourWriteTests.swift */, 3C5181A1B2C3D4E5F6A7B801 /* SubscriptionCreateResponseTests.swift */, @@ -2731,6 +2744,8 @@ children = ( 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, + B5481A0100000000000000A7 /* OSRequestRetryBackoffTests.swift */, + B5481A0100000000000000A5 /* OSOperationRetryTriggerTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, @@ -4669,6 +4684,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 +4764,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 +4862,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 */, 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..3571a65be --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift @@ -0,0 +1,105 @@ +/* + 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 + + 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() { + self.init(uptime: { ProcessInfo.processInfo.systemUptime }, 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() + } + } + + /// 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 + } + } + + /// Ends every backoff early, except what a server's `Retry-After` asked for. + public func retryNow() { + lock.withLock { + let enumerator = entries.objectEnumerator() + while let entry = enumerator?.nextObject() as? Entry { + entry.retryAt = entry.retryAfterAt + } + } + } + + /// Only the delay-seconds form is supported, not the HTTP-date form. + static func retryAfterSeconds(_ error: OneSignalClientError) -> TimeInterval? { + let value = error.responseHeaders?.first { key, _ in + (key as? String)?.caseInsensitiveCompare("Retry-After") == .orderedSame + }?.value + if let value { + return TimeInterval("\(value)".trimmingCharacters(in: .whitespaces)) ?? defaultRetryAfterSeconds + } + 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..663b7a6dc --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift @@ -0,0 +1,99 @@ +/* + 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 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 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..eea35c878 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() @@ -337,7 +343,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 +408,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 +420,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 +455,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 +467,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 +518,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..48c534943 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -38,16 +38,23 @@ class OSUserExecutor { private let newRecordsState: OSNewRecordsState private let identityVerificationService: OSIdentityVerificationService private let auth: OSRequestAuthorizing + private let retryBackoff: OSRequestRetryBackoff /// 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,6 +333,17 @@ class OSUserExecutor { } } + /// Backs off, then re-sends. Requests behind this one keep waiting, so order is preserved. + private func retryLater(_ request: OSUserRequest, error: OneSignalClientError) { + let delay = retryBackoff.recordFailure(request, error: error) + dispatchQueue.async { + request.sentToClient = false + } + dispatchQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?._executePendingRequests() + } + } + private func _executePendingRequests() { // Hold until known: a Create User sent now would go out unsigned if `requirement` later becomes on. guard identityVerificationService.requirement != .unknown else { @@ -404,7 +422,7 @@ extension OSUserExecutor { } func executeCreateUserRequest(_ request: OSRequestCreateUser) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } @@ -485,6 +503,8 @@ extension OSUserExecutor { // We will retry this request on a new session OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true request.sentToClient = false + } else { + self.retryLater(request, error: error) } } } @@ -500,7 +520,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 +558,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 +579,7 @@ extension OSUserExecutor { } func executeIdentifyUserRequest(_ request: OSRequestIdentifyUser) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } @@ -624,6 +646,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 +662,7 @@ extension OSUserExecutor { } func executeFetchUserRequest(_ request: OSRequestFetchUser) { - guard !request.sentToClient else { + guard !request.sentToClient, !retryBackoff.isWaiting(request) else { return } @@ -694,6 +718,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.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 1e5dc6a11..c8795a9be 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 + private let retryBackoff = OSRequestRetryBackoff() + private 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,22 @@ 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 + ) // 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 @@ -735,6 +750,23 @@ extension OneSignalUserManagerImpl { updatePropertiesDeltas(property: .session_time, value: sessionTime.intValue) } + @objc + public func onFocus() { + retryTrigger.onFocus() + } + + @objc + public func onUnfocus() { + retryTrigger.onUnfocus() + } + + /// Sends failed Requests now instead of waiting out their backoff. + private func retryNow() { + retryBackoff.retryNow() + userExecutor?.executePendingRequests() + operationRepo.addFlushDeltaQueueToDispatchQueue() + } + /** App has been backgrounded. Run background tasks such to flush the operation repo and hydrating models. Need to consider app killed vs app backgrounded and handle gracefully. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift new file mode 100644 index 000000000..4cf872a8f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift @@ -0,0 +1,180 @@ +/* + 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() + 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 cachedAddAliasesCount() -> Int { + let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] + return cached?.count ?? 0 + } + + private func flush(_ executor: OSIdentityOperationExecutor) { + executor.processDeltaQueue(inBackground: false) + allowAsyncWorkToRun(seconds: 0.2) + } + + func testARetryableFailureIsResentOnceTheBackoffPasses() { + let executor = cachedAddAliases(failingWith: error(500)) + + flush(executor) + XCTAssertEqual(addAliasesCount(), 1) + + flush(executor) + XCTAssertEqual(addAliasesCount(), 1, "still backing off") + + uptime += OSRequestRetryBackoff.baseDelaySeconds + flush(executor) + XCTAssertEqual(addAliasesCount(), 2) + XCTAssertEqual(cachedAddAliasesCount(), 1, "kept until it succeeds") + } + + func testASuccessfulRetryRemovesTheRequest() { + let executor = cachedAddAliases(failingWith: error(0)) + flush(executor) + + client.setMockResponseForRequest(request: addAliasesDescription, response: [:]) + uptime += OSRequestRetryBackoff.baseDelaySeconds + flush(executor) + + XCTAssertEqual(addAliasesCount(), 2) + XCTAssertEqual(cachedAddAliasesCount(), 0) + } + + func testANonRetryableFailureIsNotRetried() { + let executor = cachedAddAliases(failingWith: error(400)) + + flush(executor) + uptime += OSRequestRetryBackoff.maxDelaySeconds + flush(executor) + + XCTAssertEqual(addAliasesCount(), 1) + XCTAssertEqual(cachedAddAliasesCount(), 0) + } + + func testRetryNowEndsTheBackoff() { + let executor = cachedAddAliases(failingWith: error(500)) + flush(executor) + + backoff.retryNow() + flush(executor) + + XCTAssertEqual(addAliasesCount(), 2) + } + + func testRetryNowStillWaitsOutRetryAfter() { + let executor = cachedAddAliases(failingWith: error(429, headers: ["Retry-After": "120"])) + flush(executor) + + backoff.retryNow() + uptime += 119 + flush(executor) + XCTAssertEqual(addAliasesCount(), 1) + + uptime += 1 + flush(executor) + XCTAssertEqual(addAliasesCount(), 2) + } + + /// The User executor is not polled, so it schedules its own retry, and holds the Requests behind it. + func testTheUserExecutorRetriesAFailedFetchUserInTheSameRun() { + let shortBackoff = OSRequestRetryBackoff(uptime: { ProcessInfo.processInfo.systemUptime }, jitter: { 0.01 }) + let fetch = "" + let executor = OSUserExecutor( + newRecordsState: newRecordsState, + identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, + auth: OneSignalUserManagerImpl.sharedInstance.requestAuth, + retryBackoff: shortBackoff + ) + 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.client.hasExecutedRequestOfType(OSRequestFetchUser.self) + } + + // 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) + OneSignalCoreMocks.waitUntil("The Fetch User was not retried until it succeeded") { + self.client.executedRequests.filter { $0 is OSRequestFetchUser }.count >= 2 && executor.userRequestQueue.isEmpty + } + allowAsyncWorkToRun() + } +} 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]; From 47ef1d6603233ef9b87125fba875502bd848b550 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 12 Oct 2026 13:11:35 -0700 Subject: [PATCH 02/11] chore: [SDK-5481] move retry entry points out of the User Manager file Keeps OneSignalUserManagerImpl.swift under SwiftLint's 1000 line limit. Co-Authored-By: Claude Opus 5.5 --- .../OneSignal.xcodeproj/project.pbxproj | 4 ++ .../OneSignalUserManagerImpl+Retry.swift | 48 +++++++++++++++++++ .../Source/OneSignalUserManagerImpl.swift | 27 ++--------- 3 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 57f6fa32d..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 */; }; @@ -1610,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 = ""; }; @@ -2983,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 */, @@ -5007,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/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift new file mode 100644 index 000000000..7aa15b9e4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift @@ -0,0 +1,48 @@ +/* + 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. + func retryNow() { + retryBackoff.retryNow() + userExecutor?.executePendingRequests() + operationRepo.addFlushDeltaQueueToDispatchQueue() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index c8795a9be..39306017c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -210,8 +210,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener let pushSubscriptionModelStoreListener: OSSubscriptionModelStoreListener - private let retryBackoff = OSRequestRetryBackoff() - private let retryTrigger = OSOperationRetryTrigger() + let retryBackoff = OSRequestRetryBackoff() + let retryTrigger = OSOperationRetryTrigger() // Executors must be initialize after sharedInstance is initialized var userExecutor: OSUserExecutor? @@ -326,11 +326,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // Cannot initialize these executors in `init` as they reference the sharedInstance 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 subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth, retryBackoff: retryBackoff) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor @@ -750,23 +746,6 @@ extension OneSignalUserManagerImpl { updatePropertiesDeltas(property: .session_time, value: sessionTime.intValue) } - @objc - public func onFocus() { - retryTrigger.onFocus() - } - - @objc - public func onUnfocus() { - retryTrigger.onUnfocus() - } - - /// Sends failed Requests now instead of waiting out their backoff. - private func retryNow() { - retryBackoff.retryNow() - userExecutor?.executePendingRequests() - operationRepo.addFlushDeltaQueueToDispatchQueue() - } - /** App has been backgrounded. Run background tasks such to flush the operation repo and hydrating models. Need to consider app killed vs app backgrounded and handle gracefully. From b9b052c38893172ba5f1ce4baaaf4efcfb4f7567 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 12 Oct 2026 13:14:28 -0700 Subject: [PATCH 03/11] fix: [SDK-5481] cap Retry-After and skip no-op retry wakes Retry-After must be a finite, non-negative delay and is capped at an hour. The foreground and network triggers only wake the executors when a backoff actually ended, so held work such as a paused Create User is not resent on every foreground. Backoff now uses a clock that counts sleep. Co-Authored-By: Claude Opus 5.5 --- .../Source/OSRequestRetryBackoff.swift | 27 ++++++++++++++---- .../OSRequestRetryBackoffTests.swift | 28 +++++++++++++++++++ .../OneSignalUserManagerImpl+Retry.swift | 7 +++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift index 3571a65be..fdbf4faa7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift @@ -39,6 +39,7 @@ 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 @@ -52,7 +53,8 @@ public final class OSRequestRetryBackoff { private let jitter: () -> Double public convenience init() { - self.init(uptime: { ProcessInfo.processInfo.systemUptime }, jitter: { Double.random(in: 0.5...1) }) + // 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) { @@ -82,23 +84,36 @@ public final class OSRequestRetryBackoff { } } - /// Ends every backoff early, except what a server's `Retry-After` asked for. - public func retryNow() { + /// 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 { - entry.retryAt = entry.retryAfterAt + 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. + /// 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 { - return TimeInterval("\(value)".trimmingCharacters(in: .whitespaces)) ?? defaultRetryAfterSeconds + 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/OSRequestRetryBackoffTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift index 663b7a6dc..9aae71a32 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSRequestRetryBackoffTests.swift @@ -83,6 +83,34 @@ final class OSRequestRetryBackoffTests: XCTestCase { 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() diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift index 7aa15b9e4..1c6aabab6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Retry.swift @@ -39,9 +39,12 @@ extension OneSignalUserManagerImpl { retryTrigger.onUnfocus() } - /// Sends failed Requests now instead of waiting out their backoff. + /// 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() { - retryBackoff.retryNow() + guard retryBackoff.retryNow() else { + return + } userExecutor?.executePendingRequests() operationRepo.addFlushDeltaQueueToDispatchQueue() } From fa2c5b438469e594685b3f40c8f12625a0de80d5 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 12 Oct 2026 13:48:00 -0700 Subject: [PATCH 04/11] test: [SDK-5481] wait on conditions in executor retry tests Fixed sleeps were too short on CI, and a late response leaked into the next test. Co-Authored-By: Claude Opus 5.5 --- .../Source/OSRequestRetryBackoff.swift | 13 ++++ .../Executors/ExecutorRetryBackoffTests.swift | 65 ++++++++++--------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift index fdbf4faa7..374f853e6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSRequestRetryBackoff.swift @@ -68,6 +68,19 @@ public final class OSRequestRetryBackoff { } } + 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 { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift index 4cf872a8f..c259c2046 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorRetryBackoffTests.swift @@ -48,6 +48,7 @@ final class ExecutorRetryBackoffTests: XCTestCase { OneSignalIdentifiers.currentAppId = "test-app-id" client = MockOneSignalClient() + client.executeInstantaneously = true OneSignalCoreImpl.setSharedClient(client) newRecordsState = MockNewRecordsState() uptime = 1_000 @@ -77,76 +78,80 @@ final class ExecutorRetryBackoffTests: XCTestCase { return client.executedRequests.filter { $0 is OSRequestAddAliases }.count } - private func cachedAddAliasesCount() -> Int { + private func cachedAddAliases() -> [OSRequestAddAliases] { let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, defaultValue: []) as? [OSRequestAddAliases] - return cached?.count ?? 0 + return cached ?? [] } - private func flush(_ executor: OSIdentityOperationExecutor) { + /// Flushes, then waits until the attempt has been sent and its response handled. + private func flushAndWaitForAttempt(_ executor: OSIdentityOperationExecutor, number: Int) { executor.processDeltaQueue(inBackground: false) - allowAsyncWorkToRun(seconds: 0.2) + 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)) - flush(executor) - XCTAssertEqual(addAliasesCount(), 1) - - flush(executor) - XCTAssertEqual(addAliasesCount(), 1, "still backing off") + flushAndWaitForAttempt(executor, number: 1) + flushExpectingNoAttempt(executor) uptime += OSRequestRetryBackoff.baseDelaySeconds - flush(executor) - XCTAssertEqual(addAliasesCount(), 2) - XCTAssertEqual(cachedAddAliasesCount(), 1, "kept until it succeeds") + flushAndWaitForAttempt(executor, number: 2) + XCTAssertEqual(cachedAddAliases().count, 1, "kept until it succeeds") } func testASuccessfulRetryRemovesTheRequest() { let executor = cachedAddAliases(failingWith: error(0)) - flush(executor) + flushAndWaitForAttempt(executor, number: 1) client.setMockResponseForRequest(request: addAliasesDescription, response: [:]) uptime += OSRequestRetryBackoff.baseDelaySeconds - flush(executor) + executor.processDeltaQueue(inBackground: false) - XCTAssertEqual(addAliasesCount(), 2) - XCTAssertEqual(cachedAddAliasesCount(), 0) + OneSignalCoreMocks.waitUntil("The retried Add Aliases was not removed") { + self.addAliasesCount() == 2 && self.cachedAddAliases().isEmpty + } } func testANonRetryableFailureIsNotRetried() { let executor = cachedAddAliases(failingWith: error(400)) - flush(executor) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitUntil("The rejected Add Aliases was not removed") { + self.addAliasesCount() == 1 && self.cachedAddAliases().isEmpty + } uptime += OSRequestRetryBackoff.maxDelaySeconds - flush(executor) - - XCTAssertEqual(addAliasesCount(), 1) - XCTAssertEqual(cachedAddAliasesCount(), 0) + flushExpectingNoAttempt(executor) } func testRetryNowEndsTheBackoff() { let executor = cachedAddAliases(failingWith: error(500)) - flush(executor) + flushAndWaitForAttempt(executor, number: 1) backoff.retryNow() - flush(executor) - - XCTAssertEqual(addAliasesCount(), 2) + flushAndWaitForAttempt(executor, number: 2) } func testRetryNowStillWaitsOutRetryAfter() { let executor = cachedAddAliases(failingWith: error(429, headers: ["Retry-After": "120"])) - flush(executor) + flushAndWaitForAttempt(executor, number: 1) backoff.retryNow() uptime += 119 - flush(executor) - XCTAssertEqual(addAliasesCount(), 1) + flushExpectingNoAttempt(executor) uptime += 1 - flush(executor) - XCTAssertEqual(addAliasesCount(), 2) + flushAndWaitForAttempt(executor, number: 2) } /// The User executor is not polled, so it schedules its own retry, and holds the Requests behind it. From 435624e0e5521afbba574a27af21632db57071ff Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:12:24 -0700 Subject: [PATCH 05/11] test: [SDK-5481] add demo fault-injection scripts for retry testing Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +- examples/demo/scripts/retry/README.md | 107 ++++++++ examples/demo/scripts/retry/fault_proxy.py | 110 +++++++++ examples/demo/scripts/retry/report.py | 121 +++++++++ examples/demo/scripts/retry/retry-test.sh | 271 +++++++++++++++++++++ 5 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 examples/demo/scripts/retry/README.md create mode 100644 examples/demo/scripts/retry/fault_proxy.py create mode 100644 examples/demo/scripts/retry/report.py create mode 100755 examples/demo/scripts/retry/retry-test.sh diff --git a/.gitignore b/.gitignore index 0e7dffa5d..7bcb1bd91 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ iOS_SDK/Carthage/Build .build/ examples/demo/App/Secrets.plist -examples/demo/Local.xcconfig \ No newline at end of file +examples/demo/Local.xcconfig +examples/demo/scripts/retry/.state/ diff --git a/examples/demo/scripts/retry/README.md b/examples/demo/scripts/retry/README.md new file mode 100644 index 000000000..ca5da8e3b --- /dev/null +++ b/examples/demo/scripts/retry/README.md @@ -0,0 +1,107 @@ +# Retry test harness + +Injects failures into the SDK's requests from the demo app, so executor retries and backoff can be +watched and checked. A [mitmproxy](https://mitmproxy.org) addon fails only requests that carry the +SDK's `SDK-Version` header. The demo's own REST calls and all other traffic pass through. Only +`onesignal.com` traffic is decrypted. + +```bash +brew install mitmproxy +cd examples/demo/scripts/retry +./retry-test.sh help +``` + +## Setup + +1. Build and install the demo app on a simulator (see `examples/demo/README.md`), launch it once, + and allow notifications. The prompt holds the app inactive, so foreground retries never fire + while it is showing. Use `fresh` instead of reinstalling, so the permission is kept. +2. `./retry-test.sh start` starts mitmproxy on port 8899, points the Mac's Wi-Fi/Ethernet HTTP(S) + proxy at it (the simulator uses the Mac's proxy), and trusts its CA on the booted simulator. +3. `./retry-test.sh fresh` wipes the demo's data and relaunches it as a new install. +4. In another terminal, `./retry-test.sh watch` follows each SDK request. +5. When done, **`./retry-test.sh stop`** restores the Mac's proxy settings. + +Leave the Mac's proxy alone with `RETRY_TEST_PROXY_MODE=local` (mitmproxy captures the `App` +process, which needs its network extension approved once). For a physical device, use +`RETRY_TEST_PROXY_MODE=manual` and set the device's Wi-Fi proxy to the Mac, as `start` explains. + +## Commands + +| Command | Does | +| --- | --- | +| `fail [--times N] [--retry-after S] [--add]` | Fail matching requests. Status `0` drops the connection, like being offline. Without `--times` it fails until `clear`. `--add` keeps earlier rules. Applies right away. | +| `clear` / `rules` | Remove or show the rules | +| `report [regex]` | Per route: each attempt, the gap since the previous one, and the gap expected | +| `reset-log` | Forget recorded attempts, for example between scenarios | +| `fresh` / `relaunch` | New install without reinstalling / kill and relaunch | +| `background` / `foreground` | Background the demo by opening Settings / bring it back | +| `network-flap [s]` | Turn the Mac's Wi-Fi off for `s` seconds (default 10). Drops your Mac's connection too. | +| `logs` | Stream the demo's SDK request and retry logs | + +Routes: `create-user`, `fetch-user`, `update-user` (tags, language, session data), `identity` (add +alias and login's Identify User share it), `remove-alias`, `create-sub`, `update-sub`, +`delete-sub`, `identity-by-sub`, `custom-events`, `all`. + +## Reading `report` + +``` +POST apps//users (User executor) + # time status body gap expected note + 1 0.0s 408* edc1f203 + 2 2.8s 408* 8f1a8f24 2.8s 2.5-5.0s executor backoff #1 + 3 11.4s 408* d85e5a15 8.6s 5.0-10.0s executor backoff #2 + 4 31.0s 201 3a910c5a 19.6s 10.0-20.0s executor backoff #3 +``` + +What the SDK should do: + +- **Executor backoff**: 5s, doubling to 300s, times a jitter of 0.5 to 1. `Retry-After` is the + minimum wait (capped at 1h), and a 429 without one waits 60s. +- **Client reattempts first**: `OneSignalClient` itself re-sends a 5xx or status 0 four times + (5, 15, 45, 135s) before the executor sees the failure. That count is kept per request for the + run, so later failures of the same request go straight to the executor backoff. Use a + retryable status the client does not reattempt, like **408**, to see the executor backoff right + away. +- **Repo executors** (everything but the User executor) only resend on the 5s poll, so their window + allows up to 5s more. The User executor wakes itself on time. +- **Foreground, and network return while in the foreground, end the executor backoff early**, but + not `Retry-After`. `EARLY` right after those is expected. One case happens without you doing + anything: at cold start, the first failure often lands just before the app goes active, so the + second attempt follows within a second. +- `body` is a hash of the request body. A new hash on each attempt is normal, since bodies hold + timestamps. + +## Scenarios + +Run `reset-log` before each one. Unless noted, the request should succeed after the last failure, +and nothing should need a relaunch. + +1. **Exponential backoff, User executor**: `fail create-user 408 --times 4`, then `fresh`. Gaps + should fall in the windows above, then 201. +2. **Repo executor**: `fail update-user 408 --times 3`, then add a tag in the demo. The PATCH + retries on the next poll after each backoff. +3. **Retry-After**: `fail update-user 429 --times 2 --retry-after 20`, then add a tag. Gaps of at + least 20s. `background` then `foreground` during the wait must not cut it short. +4. **429 without Retry-After**: `fail update-user 429 --times 1`, then add a tag. About 60s. +5. **5xx**: `fail update-user 503 --times 6`, then add a tag. Four client reattempts (5, 15, 45, + 135s), then executor backoff. Slow, about 3.5 minutes. +6. **Offline**: `fail all 0`, change tags, aliases and email, wait for a few failures, then `clear`. + Everything goes out by the next backoff, or right away after `foreground`. +7. **Foreground ends the backoff**: `fail create-user 408 --times 6`, then `fresh`. After about + 25s, `background`, wait, `foreground`. An attempt goes out right as the app becomes active. +8. **Network return**: as in 7, but `network-flap 5` with the app in the foreground. An attempt goes + out when Wi-Fi comes back. Repeat with the app in the background: no attempt then. +9. **Order is kept**: `fail create-user 408 --times 3`, then `fresh` and log in during the + failures. Identify User (PATCH `.../identity`) is not sent until Create User succeeds. +10. **Fetch User**: `fail fetch-user 408 --times 3`, then log in to an existing external ID. + Fetch User retries in the same run, and later user requests wait for it. +11. **Kill during backoff**: `fail update-user 408`, add a tag, wait for two failures, `clear`, + then `relaunch`. The cached request is sent right after launch. +12. **Non-retryable is not retried**: `fail update-user 400 --times 1`, then add a tag. One attempt, + no retries. +13. **Update Subscription no longer retries every 5s**: `fail update-sub 408 --times 4`, then + toggle push in the demo. Gaps grow instead of repeating every 5s. +14. **Login/logout while failing**: `fail all 408`, log in, log out, log in as someone else, then + `clear`. Requests for each user go out in order, with no errors about the wrong user. +15. **Identity Verification**: repeat 1, 2 and 9 with an app that requires JWT. diff --git a/examples/demo/scripts/retry/fault_proxy.py b/examples/demo/scripts/retry/fault_proxy.py new file mode 100644 index 000000000..5477300ea --- /dev/null +++ b/examples/demo/scripts/retry/fault_proxy.py @@ -0,0 +1,110 @@ +""" +mitmproxy addon that injects failures into OneSignal SDK requests so executor retries can be +observed in the demo app. Driven by `retry-test.sh`; see README.md. + +Only requests carrying the SDK's `SDK-Version` header are touched, so the demo's own REST calls +pass through. Every SDK request is appended to attempts.jsonl for `retry-test.sh report`. + +Rules are re-read whenever rules.json changes. Each rule: + {"route": "update-user", "method": "PATCH", "path": "", "status": 503, + "retry_after": "30", "times": 3} +`status` 0 drops the connection (the SDK sees status 0, like being offline). `times` is how many +matching requests fail before the rule passes them through; null fails forever. +""" + +import hashlib +import json +import os +import re +import time + +from mitmproxy import http + +STATE_DIR = os.environ["RETRY_TEST_STATE_DIR"] +RULES_PATH = os.path.join(STATE_DIR, "rules.json") +ATTEMPTS_PATH = os.path.join(STATE_DIR, "attempts.jsonl") + +API_PATH_PREFIX = re.compile(r"^/(api/v1/)?") + + +class FaultProxy: + def __init__(self): + self.rules_mtime = None + self.rules = [] + self.remaining = [] + + def load_rules(self): + try: + mtime = os.stat(RULES_PATH).st_mtime_ns + except FileNotFoundError: + mtime = None + if mtime == self.rules_mtime: + return + self.rules_mtime = mtime + self.rules = [] + if mtime is not None: + with open(RULES_PATH) as f: + self.rules = json.load(f) + self.remaining = [rule.get("times") for rule in self.rules] + print(f"[fault] rules: {json.dumps(self.rules) if self.rules else 'none, passing everything'}") + + def match(self, flow): + path = API_PATH_PREFIX.sub("", flow.request.path.split("?")[0]) + for i, rule in enumerate(self.rules): + if rule.get("method") and rule["method"] != flow.request.method: + continue + if not re.search(rule["path"], path): + continue + if self.remaining[i] is not None: + if self.remaining[i] <= 0: + continue + self.remaining[i] -= 1 + return rule + return None + + def request(self, flow: http.HTTPFlow): + if "onesignal.com" not in flow.request.pretty_host or "SDK-Version" not in flow.request.headers: + return + self.load_rules() + rule = self.match(flow) + flow.metadata["fault_rule"] = rule + if rule is None: + return + if rule["status"] == 0: + self.record(flow, 0, rule) + flow.kill() + return + headers = {"Content-Type": "application/json"} + if rule.get("retry_after") is not None: + headers["Retry-After"] = str(rule["retry_after"]) + body = json.dumps({"errors": [{"title": f"Injected by fault_proxy ({rule['route']})"}]}) + flow.response = http.Response.make(rule["status"], body, headers) + + def response(self, flow: http.HTTPFlow): + if "fault_rule" not in flow.metadata: + return + self.record(flow, flow.response.status_code, flow.metadata["fault_rule"]) + + def error(self, flow: http.HTTPFlow): + if "fault_rule" in flow.metadata and flow.metadata["fault_rule"] is None: + self.record(flow, 0, None) + + def record(self, flow, status, rule): + path = API_PATH_PREFIX.sub("", flow.request.path.split("?")[0]) + entry = { + "t": time.time(), + "method": flow.request.method, + "path": path, + "status": status, + "injected": rule is not None, + "retry_after": rule.get("retry_after") if rule else None, + "body": hashlib.sha1(flow.request.raw_content or b"").hexdigest()[:8], + } + with open(ATTEMPTS_PATH, "a") as f: + f.write(json.dumps(entry) + "\n") + tag = "INJECTED" if rule else "passed" + stamp = time.strftime("%H:%M:%S", time.localtime(entry["t"])) + print(f"[fault] {stamp} {status:>3} {tag:<8} {flow.request.method:<6} {path} body={entry['body']}", flush=True) + + +addons = [FaultProxy()] diff --git a/examples/demo/scripts/retry/report.py b/examples/demo/scripts/retry/report.py new file mode 100644 index 000000000..504d9c9fc --- /dev/null +++ b/examples/demo/scripts/retry/report.py @@ -0,0 +1,121 @@ +""" +Prints each SDK route's attempts from attempts.jsonl with the gap since the previous attempt, and +the gap the SDK should have waited. Usage: report.py [route regex] + +Expected gaps model the SDK's two retry layers: + - OneSignalClient re-sends a 5xx or status 0 up to 4 times itself (5, 15, 45, 135s). The count + lives on the request object, so a request only gets those once per run. + - Then the executor backs off: 5s doubling to 300s, times jitter in [0.5, 1]. Retry-After, or + 60s for a 429 without one, is the minimum. Repo executors (everything except the User + executor) only resend on their 5s poll, so up to 5s more is allowed. +A foreground or network return ends an executor backoff early, so short gaps after those are fine. +""" + +import json +import re +import sys +from collections import OrderedDict + +CLIENT_DELAYS = [5, 15, 45, 135] +POLL_SLACK = 5.5 +CLOCK_SLACK = 1.0 +# Login's Identify User shares PATCH .../identity with Add Aliases, so it is judged with the looser +# repo executor window. +USER_EXECUTOR = re.compile(r"^POST apps/[^/]+/users$|^GET apps/[^/]+/users/by/|^GET apps/[^/]+/subscriptions/[^/]+/user/identity$") +EXECUTOR_ROUTE = re.compile(r"^apps/[^/]+/(users(/by/.*)?|subscriptions/[^/]+|custom_events)$") +ID_SEGMENT = re.compile(r"(by/[^/]+/|subscriptions/)[^/]+") + + +def route_key(entry): + path = ID_SEGMENT.sub(r"\1:id", entry["path"]) + return f"{entry['method']} {path}" + + +def is_user_executor(entry): + return bool(USER_EXECUTOR.search(f"{entry['method']} {entry['path']}")) + + +def executor_window(failures, status, retry_after): + exponential = min(5 * 2 ** (failures - 1), 300) + low, high = exponential * 0.5, exponential + floor = None + if retry_after is not None: + try: + floor = min(max(float(retry_after), 0), 3600) + except ValueError: + floor = 60 + elif status == 429: + floor = 60 + if floor is not None: + low, high = max(low, floor), max(high, floor) + return low, high, floor is not None + + +def main(): + path = sys.argv[1] + route_filter = re.compile(sys.argv[2]) if len(sys.argv) > 2 else None + groups = OrderedDict() + with open(path) as f: + for line in f: + if line.strip(): + entry = json.loads(line) + groups.setdefault(route_key(entry), []).append(entry) + + if not groups: + print("No SDK requests recorded yet.") + return + + for key, entries in groups.items(): + if route_filter and not route_filter.search(key): + continue + if not EXECUTOR_ROUTE.search(entries[0]["path"]): + continue + repo = not is_user_executor(entries[0]) + print(f"\n{key} ({'repo executor, 5s poll' if repo else 'User executor'})") + print(f" {'#':>3} {'time':>8} {'status':>6} {'body':>8} {'gap':>8} {'expected':>16} note") + client_retries = 0 + executor_failures = 0 + expected = None + start = entries[0]["t"] + for i, entry in enumerate(entries): + gap = entry["t"] - entries[i - 1]["t"] if i else None + note = "" + if expected and gap is not None: + low, high, kind = expected + if gap < low - CLOCK_SLACK: + note = f"EARLY ({kind})" + if kind.startswith("executor") and not kind.endswith("Retry-After"): + note += "; fine if right after a foreground or network return" + elif gap > high + CLOCK_SLACK: + note = f"LATE ({kind})" + else: + note = kind + elif gap is not None: + note = "new attempt after success" + expected_text = f"{expected[0]:.1f}-{expected[1]:.1f}s" if expected else "" + gap_text = f"{gap:.1f}s" if gap is not None else "" + mark = "*" if entry["injected"] else " " + print(f" {i + 1:>3} {entry['t'] - start:>7.1f}s {entry['status']:>5}{mark} {entry.get('body', ''):>8} {gap_text:>8} {expected_text:>16} {note}") + + status = entry["status"] + if 200 <= status < 300: + client_retries = executor_failures = 0 + expected = None + elif status in (400, 401, 402, 403, 404, 409, 410): + expected = None + elif (status >= 500 or status == 0) and client_retries < len(CLIENT_DELAYS): + delay = CLIENT_DELAYS[client_retries] + client_retries += 1 + expected = (delay, delay, "client reattempt") + else: + executor_failures += 1 + low, high, has_floor = executor_window(executor_failures, status, entry.get("retry_after")) + if repo: + high += POLL_SLACK + kind = f"executor backoff #{executor_failures}" + expected = (low, high, kind + (", Retry-After" if has_floor else "")) + print("\n* = failure injected by the proxy") + + +if __name__ == "__main__": + main() diff --git a/examples/demo/scripts/retry/retry-test.sh b/examples/demo/scripts/retry/retry-test.sh new file mode 100755 index 000000000..e3341abd7 --- /dev/null +++ b/examples/demo/scripts/retry/retry-test.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# Fault-injection harness for manually testing SDK request retries in the demo app. +# Run `./retry-test.sh help` or see README.md. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE_DIR="${RETRY_TEST_STATE_DIR:-$SCRIPT_DIR/.state}" +RULES="$STATE_DIR/rules.json" +ATTEMPTS="$STATE_DIR/attempts.jsonl" +PROXY_LOG="$STATE_DIR/proxy.log" +PID_FILE="$STATE_DIR/proxy.pid" +SAVED_PROXY="$STATE_DIR/saved-proxy" +PORT="${RETRY_TEST_PORT:-8899}" +# `system` points the Mac's HTTP(S) proxy at mitmproxy (the simulator uses the Mac's settings). +# `local` uses mitmproxy's local capture of the `App` process instead, which needs its macOS +# network extension approved once, but leaves the Mac's proxy alone. +# `manual` only listens; point a physical device's Wi-Fi proxy at this Mac yourself. +PROXY_MODE="${RETRY_TEST_PROXY_MODE:-system}" +BUNDLE_ID="${RETRY_TEST_BUNDLE_ID:-com.onesignal.example}" +CA_CERT="$HOME/.mitmproxy/mitmproxy-ca-cert.pem" + +export RETRY_TEST_STATE_DIR="$STATE_DIR" +mkdir -p "$STATE_DIR" + +die() { echo "error: $*" >&2; exit 1; } + +route_rule() { + # Prints " " for a route name. Paths are relative to https://api.onesignal.com/. + case "$1" in + create-user) echo "POST ^apps/[^/]+/users$" ;; + fetch-user) echo "GET ^apps/[^/]+/users/by/[^/]+/[^/]+$" ;; + update-user) echo "PATCH ^apps/[^/]+/users/by/[^/]+/[^/]+$" ;; + identity) echo "PATCH /identity$" ;; + remove-alias) echo "DELETE /identity/[^/]+$" ;; + create-sub) echo "POST /subscriptions$" ;; + update-sub) echo "PATCH ^apps/[^/]+/subscriptions/[^/]+$" ;; + delete-sub) echo "DELETE ^apps/[^/]+/subscriptions/[^/]+$" ;; + identity-by-sub) echo "GET /user/identity$" ;; + custom-events) echo "POST /custom_events$" ;; + all) echo "- ." ;; + *) die "unknown route '$1'. Routes: create-user fetch-user update-user identity remove-alias create-sub update-sub delete-sub identity-by-sub custom-events all" ;; + esac +} + +proxy_running() { [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; } + +network_service() { + local iface + iface="$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}')" + [[ -n "$iface" ]] || die "no default network route" + networksetup -listnetworkserviceorder | awk -v dev="$iface" ' + /^\([0-9]+\)/ { sub(/^\([0-9]+\) /, ""); name = $0 } + $0 ~ "Device: " dev "\\)" { print name; exit }' +} + +set_system_proxy() { + local service + service="$(network_service)" + [[ -n "$service" ]] || die "could not map the default interface to a network service" + { + echo "$service" + networksetup -getwebproxy "$service" | awk -F': ' '/^(Enabled|Server|Port)/ {print $2}' | paste -sd' ' - + networksetup -getsecurewebproxy "$service" | awk -F': ' '/^(Enabled|Server|Port)/ {print $2}' | paste -sd' ' - + } > "$SAVED_PROXY" + networksetup -setwebproxy "$service" 127.0.0.1 "$PORT" + networksetup -setsecurewebproxy "$service" 127.0.0.1 "$PORT" + echo "Pointed '$service' HTTP(S) proxy at 127.0.0.1:$PORT (restored by 'stop')." +} + +restore_system_proxy() { + [[ -f "$SAVED_PROXY" ]] || return 0 + local service web secure + { read -r service; read -r web; read -r secure; } < "$SAVED_PROXY" + restore_one() { + local kind="$1" enabled server port + read -r enabled server port <<< "$2" + if [[ "$enabled" == "Yes" && -n "$server" ]]; then + networksetup "-set${kind}" "$service" "$server" "$port" + else + networksetup "-set${kind}state" "$service" off + fi + } + restore_one webproxy "$web" + restore_one securewebproxy "$secure" + rm -f "$SAVED_PROXY" + echo "Restored '$service' proxy settings." +} + +install_ca() { + for _ in $(seq 1 50); do [[ -f "$CA_CERT" ]] && break; sleep 0.1; done + [[ -f "$CA_CERT" ]] || die "mitmproxy CA not generated at $CA_CERT" + if xcrun simctl list devices booted | grep -q Booted; then + xcrun simctl keychain booted add-root-cert "$CA_CERT" + echo "Trusted the mitmproxy CA on the booted simulator(s)." + else + echo "No booted simulator. Boot one, then run '$0 trust-ca'." + fi +} + +cmd_start() { + command -v mitmdump >/dev/null || die "mitmdump not found. Install with: brew install mitmproxy" + proxy_running && die "proxy already running (pid $(cat "$PID_FILE")). Run '$0 stop' first." + : > "$ATTEMPTS" + [[ -f "$RULES" ]] || echo "[]" > "$RULES" + local mode_args=(--listen-port "$PORT") + [[ "$PROXY_MODE" == "local" ]] && mode_args=(--mode "local:App") + # Only onesignal.com is decrypted; everything else on the Mac is tunnelled untouched. + mitmdump "${mode_args[@]}" \ + --allow-hosts '^(.+\.)?onesignal\.com(:443)?$' \ + --set connection_strategy=lazy \ + --set termlog_verbosity=warn \ + -s "$SCRIPT_DIR/fault_proxy.py" > "$PROXY_LOG" 2>&1 & + echo $! > "$PID_FILE" + sleep 1 + proxy_running || { cat "$PROXY_LOG"; die "mitmdump exited"; } + echo "mitmdump running (pid $(cat "$PID_FILE")), log: $PROXY_LOG" + case "$PROXY_MODE" in + system) set_system_proxy; install_ca ;; + local) install_ca ;; + manual) + echo "Set the device's Wi-Fi HTTP proxy to $(ipconfig getifaddr en0 2>/dev/null || echo ''):$PORT," + echo "then open http://mitm.it on the device to install and trust the CA." ;; + *) die "unknown RETRY_TEST_PROXY_MODE '$PROXY_MODE'" ;; + esac + echo "Relaunch the demo app so it picks up the proxy: $0 relaunch" +} + +cmd_stop() { + if proxy_running; then + kill "$(cat "$PID_FILE")" && echo "Stopped mitmdump." + fi + rm -f "$PID_FILE" + restore_system_proxy +} + +write_rules() { + # write_rules ; written atomically so the addon never reads a partial file. + echo "$1" > "$RULES.tmp" && mv "$RULES.tmp" "$RULES" +} + +cmd_fail() { + local route="${1:-}" status="${2:-}" + [[ -n "$route" && -n "$status" ]] || die "usage: $0 fail [--times N] [--retry-after S] [--add]" + shift 2 + local times="null" retry_after="null" add=false + while [[ $# -gt 0 ]]; do + case "$1" in + --times) times="$2"; shift 2 ;; + --retry-after) retry_after="\"$2\""; shift 2 ;; + --add) add=true; shift ;; + *) die "unknown option $1" ;; + esac + done + local method path + read -r method path <<< "$(route_rule "$route")" + [[ "$method" == "-" ]] && method="" + python3 - "$RULES" "$add" "$route" "$method" "$path" "$status" "$times" "$retry_after" > "$RULES.tmp" <<'PY' +import json, sys +rules_path, add, route, method, path, status, times, retry_after = sys.argv[1:] +rules = json.load(open(rules_path)) if add == "true" else [] +rules.append({"route": route, "method": method or None, "path": path, "status": int(status), + "times": json.loads(times), "retry_after": json.loads(retry_after)}) +print(json.dumps(rules)) +PY + mv "$RULES.tmp" "$RULES" + echo "Rules now: $(cat "$RULES")" +} + +cmd_clear() { + write_rules "[]" + echo "Cleared all rules; requests pass through." +} + +cmd_reset_log() { : > "$ATTEMPTS"; echo "Cleared recorded attempts."; } + +cmd_network_flap() { + # NWPathMonitor in the simulator follows the Mac's interfaces, so turning Wi-Fi off and on + # exercises the "network returned while foregrounded" trigger. + local seconds="${1:-10}" device + device="$(networksetup -listallhardwareports | awk '/Hardware Port: Wi-Fi/ {getline; print $2}')" + [[ -n "$device" ]] || die "no Wi-Fi interface found" + echo "Wi-Fi ($device) off for ${seconds}s..." + networksetup -setairportpower "$device" off + sleep "$seconds" + networksetup -setairportpower "$device" on + echo "Wi-Fi back on." +} + +cmd_fresh() { + # Wipes the demo's data and app-group containers so the SDK starts as a new install (Create + # User and so on), without uninstalling, so an already granted notification permission is kept + # and the permission prompt does not hold the app inactive. + local data group plist + data="$(xcrun simctl get_app_container booted "$BUNDLE_ID" data)" || die "demo app not installed on the booted simulator" + xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true + # Preferences go through the simulator's cfprefsd, which would write its cached copy back. + for plist in "$data"/Library/Preferences/*.plist; do + [[ -f "$plist" ]] && xcrun simctl spawn booted defaults delete "${plist%.plist}" 2>/dev/null || true + done + rm -rf "${data:?}/Documents/"* "${data:?}/Library/Caches/"* "${data:?}/Library/Preferences/"* "${data:?}/tmp/"* + while read -r _ group; do + [[ -d "$group" ]] || continue + for plist in "$group"/Library/Preferences/*.plist; do + [[ -f "$plist" ]] && xcrun simctl spawn booted defaults delete "${plist%.plist}" 2>/dev/null || true + done + rm -rf "${group:?}/"* + done < <(xcrun simctl get_app_container booted "$BUNDLE_ID" groups 2>/dev/null) + echo "Cleared demo app data." + xcrun simctl launch booted "$BUNDLE_ID" +} + +cmd_logs() { + xcrun simctl spawn booted log stream --level debug --style compact \ + --predicate 'process == "App" AND (eventMessage CONTAINS[c] "request" OR eventMessage CONTAINS[c] "retry" OR eventMessage CONTAINS[c] "re-attempt")' +} + +usage() { + cat < + +Proxy + start Start mitmproxy, point the proxy at it, trust its CA on the booted simulator + stop Stop mitmproxy and restore proxy settings + trust-ca Trust the mitmproxy CA on the booted simulator(s) + +Faults (take effect immediately, no relaunch needed) + fail [--times N] [--retry-after S] [--add] + Fail matching SDK requests. Status 0 drops the connection. + --times N: fail N times, then pass. Default: until cleared. + --add: keep existing rules instead of replacing them. + clear Remove all rules + rules Show current rules + +Observe + watch Follow every SDK request as it passes the proxy + report [route regex] Gaps between attempts per route vs. the expected backoff + reset-log Forget recorded attempts + logs Stream the demo app's SDK request/retry logs + +App / environment + relaunch Kill and relaunch the demo app on the booted simulator + fresh Wipe the demo app's data (new SDK user) and relaunch, keeping the + notification permission. Allow the prompt once after installing. + background | foreground Send the demo app to the background, or bring it back + network-flap [seconds] Turn the Mac's Wi-Fi off and back on (default 10s) + +Routes: create-user fetch-user update-user identity remove-alias create-sub update-sub + delete-sub identity-by-sub custom-events all +Env: RETRY_TEST_PORT ($PORT), RETRY_TEST_PROXY_MODE ($PROXY_MODE), RETRY_TEST_BUNDLE_ID ($BUNDLE_ID) +EOF +} + +case "${1:-help}" in + start) cmd_start ;; + stop) cmd_stop ;; + trust-ca) install_ca ;; + fail) shift; cmd_fail "$@" ;; + clear) cmd_clear ;; + rules) cat "$RULES" 2>/dev/null || echo "[]" ;; + watch) tail -n +1 -F "$PROXY_LOG" | grep --line-buffered '^\[fault\]' ;; + report) shift; python3 "$SCRIPT_DIR/report.py" "$ATTEMPTS" "$@" ;; + reset-log) cmd_reset_log ;; + logs) cmd_logs ;; + fresh) cmd_fresh ;; + relaunch) xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true; xcrun simctl launch booted "$BUNDLE_ID" ;; + background) xcrun simctl launch booted com.apple.Preferences >/dev/null && echo "Demo app backgrounded." ;; + foreground) xcrun simctl launch booted "$BUNDLE_ID" >/dev/null && echo "Demo app foregrounded." ;; + network-flap) shift; cmd_network_flap "$@" ;; + help|-h|--help) usage ;; + *) usage; exit 1 ;; +esac From 5338491126e28cfc7d27b848908e1e052a53cc7a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:44:21 -0700 Subject: [PATCH 06/11] test: [SDK-5481] drive the demo UI from the retry harness Co-Authored-By: Claude Opus 5.5 --- examples/demo/scripts/retry/README.md | 51 +++++----- .../demo/scripts/retry/flows/_scroll-to.yaml | 11 +++ .../demo/scripts/retry/flows/add-alias.yaml | 16 ++++ .../demo/scripts/retry/flows/add-email.yaml | 13 +++ .../demo/scripts/retry/flows/add-tag.yaml | 16 ++++ .../retry/flows/allow-notifications.yaml | 6 ++ examples/demo/scripts/retry/flows/login.yaml | 13 +++ examples/demo/scripts/retry/flows/logout.yaml | 8 ++ .../demo/scripts/retry/flows/toggle-push.yaml | 8 ++ examples/demo/scripts/retry/retry-test.sh | 92 +++++++++++++++---- 10 files changed, 192 insertions(+), 42 deletions(-) create mode 100644 examples/demo/scripts/retry/flows/_scroll-to.yaml create mode 100644 examples/demo/scripts/retry/flows/add-alias.yaml create mode 100644 examples/demo/scripts/retry/flows/add-email.yaml create mode 100644 examples/demo/scripts/retry/flows/add-tag.yaml create mode 100644 examples/demo/scripts/retry/flows/allow-notifications.yaml create mode 100644 examples/demo/scripts/retry/flows/login.yaml create mode 100644 examples/demo/scripts/retry/flows/logout.yaml create mode 100644 examples/demo/scripts/retry/flows/toggle-push.yaml diff --git a/examples/demo/scripts/retry/README.md b/examples/demo/scripts/retry/README.md index ca5da8e3b..7ff3bb734 100644 --- a/examples/demo/scripts/retry/README.md +++ b/examples/demo/scripts/retry/README.md @@ -6,21 +6,25 @@ SDK's `SDK-Version` header. The demo's own REST calls and all other traffic pass `onesignal.com` traffic is decrypted. ```bash -brew install mitmproxy +brew install mitmproxy mobile-dev-inc/tap/maestro cd examples/demo/scripts/retry ./retry-test.sh help ``` +[Maestro](https://maestro.mobile.dev) drives the demo's UI (`tap`) through the flows in `flows/`. + ## Setup -1. Build and install the demo app on a simulator (see `examples/demo/README.md`), launch it once, - and allow notifications. The prompt holds the app inactive, so foreground retries never fire - while it is showing. Use `fresh` instead of reinstalling, so the permission is kept. -2. `./retry-test.sh start` starts mitmproxy on port 8899, points the Mac's Wi-Fi/Ethernet HTTP(S) - proxy at it (the simulator uses the Mac's proxy), and trusts its CA on the booted simulator. -3. `./retry-test.sh fresh` wipes the demo's data and relaunches it as a new install. -4. In another terminal, `./retry-test.sh watch` follows each SDK request. -5. When done, **`./retry-test.sh stop`** restores the Mac's proxy settings. +1. `xcrun simctl create "OneSignal Retry Test" "iPhone 17"`. The harness uses its own simulator + (`RETRY_TEST_DEVICE`, by name or UDID) because the other OneSignal demos share the bundle ID. +2. `./retry-test.sh install` builds the demo and installs it there. +3. `./retry-test.sh start` starts mitmproxy on port 8899, points the Mac's Wi-Fi/Ethernet HTTP(S) + proxy at it (the simulator uses the Mac's proxy), and trusts its CA on the simulator. +4. `./retry-test.sh fresh`, then `./retry-test.sh tap allow-notifications`. The permission prompt + holds the app inactive, so foreground retries never fire while it is showing. `fresh` wipes the + demo's data without reinstalling, so the permission is kept from then on. +5. In another terminal, `./retry-test.sh watch` follows each SDK request. +6. When done, **`./retry-test.sh stop`** restores the Mac's proxy settings. Leave the Mac's proxy alone with `RETRY_TEST_PROXY_MODE=local` (mitmproxy captures the `App` process, which needs its network extension approved once). For a physical device, use @@ -34,6 +38,8 @@ process, which needs its network extension approved once). For a physical device | `clear` / `rules` | Remove or show the rules | | `report [regex]` | Per route: each attempt, the gap since the previous one, and the gap expected | | `reset-log` | Forget recorded attempts, for example between scenarios | +| `tap [args]` | Use the demo: `add-tag `, `add-alias