diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 4b6e1c17..825bb60e 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -1978,6 +1978,28 @@ 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BAB227FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift */; }; 84F6BADD27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */; }; 84F6BADE27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */; }; + 980A406A2F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A406B2F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A406C2F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A406D2F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A406E2F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A406F2F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40702F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40712F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40722F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40732F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40742F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40752F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40762F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40772F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40782F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A40792F112EFF00F25D38 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40692F112EFF00F25D38 /* RetryStrategy.swift */; }; + 980A407B2F11304200F25D38 /* RetryStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A407A2F11304200F25D38 /* RetryStrategyTests.swift */; }; + 980A407C2F11304200F25D38 /* RetryStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A407A2F11304200F25D38 /* RetryStrategyTests.swift */; }; + 980A407E2F1130FD00F25D38 /* EventDispatcherRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A407D2F1130FD00F25D38 /* EventDispatcherRetryTests.swift */; }; + 980A407F2F1130FD00F25D38 /* EventDispatcherRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A407D2F1130FD00F25D38 /* EventDispatcherRetryTests.swift */; }; + 980A40812F11310C00F25D38 /* OdpEventManagerRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40802F11310C00F25D38 /* OdpEventManagerRetryTests.swift */; }; + 980A40822F11310C00F25D38 /* OdpEventManagerRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980A40802F11310C00F25D38 /* OdpEventManagerRetryTests.swift */; }; 980CC8F72D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; 980CC8F82D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; 980CC8F92D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; @@ -2587,6 +2609,10 @@ 84E7ABBA27D2A1F100447CAE /* ThreadSafeLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadSafeLogger.swift; sourceTree = ""; }; 84F6BAB227FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP.swift; sourceTree = ""; }; 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Decide.swift; sourceTree = ""; }; + 980A40692F112EFF00F25D38 /* RetryStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStrategy.swift; sourceTree = ""; }; + 980A407A2F11304200F25D38 /* RetryStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStrategyTests.swift; sourceTree = ""; }; + 980A407D2F1130FD00F25D38 /* EventDispatcherRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDispatcherRetryTests.swift; sourceTree = ""; }; + 980A40802F11310C00F25D38 /* OdpEventManagerRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OdpEventManagerRetryTests.swift; sourceTree = ""; }; 980CC8F62D833F0D00E07D24 /* Holdout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Holdout.swift; sourceTree = ""; }; 980CC9072D833F2800E07D24 /* ExperimentCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentCore.swift; sourceTree = ""; }; 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = ""; }; @@ -2914,6 +2940,7 @@ 6E75166E22C520D400B2B157 /* MurmurHash3.swift */, 6E75166F22C520D400B2B157 /* HandlerRegistryService.swift */, 6E75167022C520D400B2B157 /* LogMessage.swift */, + 980A40692F112EFF00F25D38 /* RetryStrategy.swift */, 84E7ABBA27D2A1F100447CAE /* ThreadSafeLogger.swift */, 6E75167122C520D400B2B157 /* AtomicProperty.swift */, 6E424BDC263228E90081004A /* AtomicArray.swift */, @@ -3121,12 +3148,15 @@ 6E75198F22C5211100B2B157 /* BucketTests_BucketVariation.swift */, 6E75198C22C5211100B2B157 /* BucketTests_ExpToVariation.swift */, 6E75198322C5211100B2B157 /* BucketTests_GroupToExp.swift */, + 980A40802F11310C00F25D38 /* OdpEventManagerRetryTests.swift */, + 980A407D2F1130FD00F25D38 /* EventDispatcherRetryTests.swift */, 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */, 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */, 6E75198422C5211100B2B157 /* BucketTests_Others.swift */, 6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */, 6E75199822C5211100B2B157 /* DataStoreTests.swift */, 6E75199022C5211100B2B157 /* DecisionListenerTests.swift */, + 980A407A2F11304200F25D38 /* RetryStrategyTests.swift */, 989428BF2DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift */, 6E981FC1232C363300FADDD6 /* DecisionListenerTests_Datafile.swift */, 6E27ECBD266FD78600B4A6D4 /* DecisionReasonsTests.swift */, @@ -4378,6 +4408,7 @@ 6E14CD762423F97900010234 /* DefaultLogger.swift in Sources */, 6E14CD922423F9A700010234 /* Project.swift in Sources */, 6E14CDA72423F9C300010234 /* LogMessage.swift in Sources */, + 980A40702F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E424C02263228FD0081004A /* AtomicDictionary.swift in Sources */, 6E14CD8C2423F9A700010234 /* Event.swift in Sources */, 6E14CDAC2423F9EB00010234 /* OTUtils.swift in Sources */, @@ -4457,6 +4488,7 @@ 6E424CF126324B620081004A /* OPTUserProfileService.swift in Sources */, 6E424CF226324B620081004A /* OPTEventDispatcher.swift in Sources */, 6E424D2E26324BBA0081004A /* MockUrlSession.swift in Sources */, + 980A40772F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E424CF326324B620081004A /* OPTDatafileHandler.swift in Sources */, 6E424CF426324B620081004A /* DecisionInfo.swift in Sources */, 6E5D120D2638DCE1000ABFC3 /* EventDispatcherTests_MultiClients.swift in Sources */, @@ -4590,6 +4622,7 @@ 6E7518A122C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E994B3525A3E6EA00999262 /* DecisionResponse.swift in Sources */, 6E7516D722C520D400B2B157 /* OPTUserProfileService.swift in Sources */, + 980A406D2F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E75195522C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E75176722C520D400B2B157 /* Utils.swift in Sources */, 6E75180522C520D400B2B157 /* DataStoreFile.swift in Sources */, @@ -4708,6 +4741,7 @@ 6E7516EA22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7518A822C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E75187822C520D400B2B157 /* Variation.swift in Sources */, + 980A406B2F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E424C07263228FD0081004A /* AtomicDictionary.swift in Sources */, 6E7517F422C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E7518FC22C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -4861,6 +4895,7 @@ 6E7518A422C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E6522E6278E4F3800954EA1 /* OdpManager.swift in Sources */, 6E75185C22C520D400B2B157 /* FeatureVariable.swift in Sources */, + 980A40712F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E20050C26B4D28500278087 /* MockLogger.swift in Sources */, 6E75176A22C520D400B2B157 /* Utils.swift in Sources */, 6E75171622C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, @@ -4939,6 +4974,7 @@ 6E75176122C520D400B2B157 /* AtomicProperty.swift in Sources */, 6E75180B22C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E6522E9278E4F3800954EA1 /* OdpManager.swift in Sources */, + 980A40752F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 98F28A272E01940500A86546 /* Cmab.swift in Sources */, 6EF8DE2324BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7517C322C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, @@ -5099,6 +5135,7 @@ 6E75172722C520D400B2B157 /* OptimizelyResult.swift in Sources */, 6E7518FD22C520D500B2B157 /* UserAttribute.swift in Sources */, 6E7518E522C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 980A40812F11310C00F25D38 /* OdpEventManagerRetryTests.swift in Sources */, 6E623F0D253F9045000617D0 /* DecisionInfo.swift in Sources */, 6E424BE9263228E90081004A /* AtomicArray.swift in Sources */, 6E9B117222C5487100C22D81 /* EventDispatcherTests.swift in Sources */, @@ -5129,10 +5166,12 @@ 6E34A6202319EBB800BAE302 /* Notifications.swift in Sources */, 6E75173322C520D400B2B157 /* Constants.swift in Sources */, 8486181C286D188B00B7F41B /* OdpSegmentApiManagerTests.swift in Sources */, + 980A407C2F11304200F25D38 /* RetryStrategyTests.swift in Sources */, 6E7518A922C520D400B2B157 /* FeatureFlag.swift in Sources */, 84B4D75D27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 6E0A72D526C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift in Sources */, 6E75173F22C520D400B2B157 /* MurmurHash3.swift in Sources */, + 980A407E2F1130FD00F25D38 /* EventDispatcherRetryTests.swift in Sources */, 6E75189D22C520D400B2B157 /* Experiment.swift in Sources */, 6E27EC9C266EF11000B4A6D4 /* OptimizelyDecisionTests.swift in Sources */, 6E7518D922C520D400B2B157 /* AttributeValue.swift in Sources */, @@ -5154,6 +5193,7 @@ 6E9B116722C5487100C22D81 /* BatchEventBuilderTests_Events.swift in Sources */, 6E75181922C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 84644AB428F0D2B3003FB9CB /* OptimizelyUserContextTests_ODP_2.swift in Sources */, + 980A40722F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E75186D22C520D400B2B157 /* Rollout.swift in Sources */, 6E4544B7270E67C800F2CEBC /* NetworkReachability.swift in Sources */, 848617D52863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, @@ -5300,6 +5340,7 @@ 6E7518CE22C520D400B2B157 /* Audience.swift in Sources */, 6E75189222C520D400B2B157 /* Project.swift in Sources */, 6E7516F822C520D400B2B157 /* OptimizelyError.swift in Sources */, + 980A40742F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 84B4D75E27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 848617E82863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 98F28A4F2E02DD6D00A86546 /* CmabClient.swift in Sources */, @@ -5396,6 +5437,7 @@ 98AC97E72DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75181F22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 84F6BADD27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */, + 980A40822F11310C00F25D38 /* OdpEventManagerRetryTests.swift in Sources */, 84E2E9472852A378001114AB /* VuidManager.swift in Sources */, 6E623F06253F9045000617D0 /* DecisionInfo.swift in Sources */, 6E424BE2263228E90081004A /* AtomicArray.swift in Sources */, @@ -5426,10 +5468,12 @@ 6E75183722C520D400B2B157 /* EventForDispatch.swift in Sources */, 6E9B115D22C5486E00C22D81 /* BatchEventBuilderTests_EventTags.swift in Sources */, 6E75173922C520D400B2B157 /* MurmurHash3.swift in Sources */, + 980A407B2F11304200F25D38 /* RetryStrategyTests.swift in Sources */, 6E9B114C22C5486E00C22D81 /* DecisionServiceTests_UserProfiles.swift in Sources */, 6E34A61A2319EBB800BAE302 /* Notifications.swift in Sources */, 6E75191B22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E75170922C520D400B2B157 /* OptimizelyClient.swift in Sources */, + 980A407F2F1130FD00F25D38 /* EventDispatcherRetryTests.swift in Sources */, 6E75178D22C520D400B2B157 /* OptimizelyClient+Extension.swift in Sources */, 6E7518EB22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E27EC9B266EF11000B4A6D4 /* OptimizelyDecisionTests.swift in Sources */, @@ -5451,6 +5495,7 @@ 980CC9082D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E9B114D22C5486E00C22D81 /* BatchEventBuilderTests_Events.swift in Sources */, 6E75188B22C520D400B2B157 /* Project.swift in Sources */, + 980A40762F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E75187F22C520D400B2B157 /* TrafficAllocation.swift in Sources */, 6E0207A8272A11CF008C3711 /* NetworkReachabilityTests.swift in Sources */, 6E7518BB22C520D400B2B157 /* Variable.swift in Sources */, @@ -5563,6 +5608,7 @@ 6E75178F22C520D400B2B157 /* OptimizelyClient+Extension.swift in Sources */, 6E9B11AD22C5489300C22D81 /* MockUrlSession.swift in Sources */, 6E6522E7278E4F3800954EA1 /* OdpManager.swift in Sources */, + 980A406F2F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E9B117C22C5488100C22D81 /* FeatureVariableTests.swift in Sources */, 6E75175F22C520D400B2B157 /* AtomicProperty.swift in Sources */, C78CAF5E2445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, @@ -5708,6 +5754,7 @@ 6E75194222C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E34A61D2319EBB800BAE302 /* Notifications.swift in Sources */, 6E7517A822C520D400B2B157 /* Array+Extension.swift in Sources */, + 980A40732F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E7518EE22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E75185222C520D400B2B157 /* ProjectConfig.swift in Sources */, 6E65230A278E688B00954EA1 /* LruCache.swift in Sources */, @@ -5818,6 +5865,7 @@ 6E75194722C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E34A6222319EBB800BAE302 /* Notifications.swift in Sources */, 6E7517AD22C520D400B2B157 /* Array+Extension.swift in Sources */, + 980A406C2F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E7518F322C520D500B2B157 /* ConditionHolder.swift in Sources */, 6E75185722C520D400B2B157 /* ProjectConfig.swift in Sources */, 6E65230F278E688B00954EA1 /* LruCache.swift in Sources */, @@ -5856,6 +5904,7 @@ 6E7517D422C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E994B3425A3E6EA00999262 /* DecisionResponse.swift in Sources */, 6E75193C22C520D500B2B157 /* OPTDecisionService.swift in Sources */, + 980A40792F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E75176622C520D400B2B157 /* Utils.swift in Sources */, 6E75177E22C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, 6E7516BE22C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, @@ -5974,6 +6023,7 @@ 6E7516E422C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7518A222C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E75187222C520D400B2B157 /* Variation.swift in Sources */, + 980A40782F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 6E424C00263228FD0081004A /* AtomicDictionary.swift in Sources */, 6E7517EE22C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B11A622C5488900C22D81 /* iOSOnlyTests.swift in Sources */, @@ -6123,6 +6173,7 @@ 75C71A3825E454460084187E /* OPTDecisionService.swift in Sources */, 75C71A3925E454460084187E /* OPTDatafileHandler.swift in Sources */, 75C71A3A25E454460084187E /* OPTBucketer.swift in Sources */, + 980A406A2F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 75C71A3B25E454460084187E /* ArrayEventForDispatch+Extension.swift in Sources */, 75C71A3C25E454460084187E /* OptimizelyClient+Extension.swift in Sources */, 75C71A3D25E454460084187E /* DataStoreQueueStackImpl+Extension.swift in Sources */, @@ -6170,6 +6221,7 @@ BD6485482491474500F30986 /* DefaultNotificationCenter.swift in Sources */, 6E994B3625A3E6EA00999262 /* DecisionResponse.swift in Sources */, BD6485492491474500F30986 /* OPTDecisionService.swift in Sources */, + 980A406E2F112EFF00F25D38 /* RetryStrategy.swift in Sources */, BD64854A2491474500F30986 /* Utils.swift in Sources */, BD64854B2491474500F30986 /* ArrayEventForDispatch+Extension.swift in Sources */, BD64854C2491474500F30986 /* DefaultEventDispatcher.swift in Sources */, diff --git a/Sources/Customization/DefaultEventDispatcher.swift b/Sources/Customization/DefaultEventDispatcher.swift index 2852d259..a7ce4a8f 100644 --- a/Sources/Customization/DefaultEventDispatcher.swift +++ b/Sources/Customization/DefaultEventDispatcher.swift @@ -53,6 +53,11 @@ open class DefaultEventDispatcher: BackgroundingCallbacks, OPTEventDispatcher { // network reachability let reachability = NetworkReachability(maxContiguousFails: 1) + // sync group used to ensure that the flushEvents is synchronous for close() + let notify = DispatchGroup() + // track if flush is currently in progress + private var isFlushing = false + public init(batchSize: Int = DefaultValues.batchSize, backingStore: DataStoreType = .file, dataStoreName: String = "OPTEventQueue", @@ -107,69 +112,111 @@ open class DefaultEventDispatcher: BackgroundingCallbacks, OPTEventDispatcher { completionHandler?(.success(event.body)) } - // notify group used to ensure that the sendEvent is synchronous. - // used in flushEvents - let notify = DispatchGroup() - + // Per-batch retry: Each batch gets up to 3 attempts with exponential backoff + // Global failure counter stops processing after 3 consecutive batch failures + open func flushEvents() { queueLock.async { - // we don't remove anthing off of the queue unless it is successfully sent. - var failureCount = 0 + guard !self.isFlushing else { return } - func removeStoredEvents(num: Int) { - if let removedItem = self.eventQueue.removeFirstItems(count: num), removedItem.count > 0 { - // avoid event-log-message preparation overheads with closure-logging - self.logger.d({ "Removed stored \(num) events starting with \(removedItem.first!)" }) - } else { - self.logger.e("Failed to removed \(num) events") - } - } + self.isFlushing = true + self.notify.enter() - while let eventsToSend: [EventForDispatch] = self.eventQueue.getFirstItems(count: self.batchSize) { - let (numEvents, batched) = eventsToSend.batch() - - guard numEvents > 0 else { break } - - guard let batchEvent = batched else { - // discard an invalid event that causes batching failure - // - if an invalid event is found while batching, it batches all the valid ones before the invalid one and sends it out. - // - when trying to batch next, it finds the invalid one at the header. It discards that specific invalid one and continue batching next ones. - - removeStoredEvents(num: 1) - continue - } - - // we've exhuasted our failure count. Give up and try the next time a event - // is queued or someone calls flush (changed to >= so that retried exactly "maxFailureCount" times). - if failureCount >= DefaultValues.maxFailureCount { - self.logger.e(.eventSendRetyFailed(failureCount)) - break - } - - // make the send event synchronous. enter our notify - self.notify.enter() - self.sendEvent(event: batchEvent) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.e(error.reason) - failureCount += 1 - case .success: - // we succeeded. remove the batch size sent. - removeStoredEvents(num: numEvents) - - // reset failureCount - failureCount = 0 + self.processNextBatch(failureCount: 0) + } + } + + private func processNextBatch(failureCount: Int) { + // Global failure counter across all batches in this flush + if failureCount >= DefaultValues.maxFailureCount { + self.logger.e(.eventSendRetyFailed(failureCount)) + self.finishFlush() + return + } + + // Check reachability + if self.reachability.shouldBlockNetworkAccess() { + self.logger.e("NetworkReachability down") + self.finishFlush() + return + } + + guard let eventsToSend: [EventForDispatch] = self.eventQueue.getFirstItems(count: self.batchSize) else { + self.finishFlush() + return + } + + let (numEvents, batchedEvent) = eventsToSend.batch() + + guard numEvents > 0 else { + self.finishFlush() + return + } + + guard let batchEvent = batchedEvent else { + // discard an invalid event that causes batching failure + // - if an invalid event is found while batching, it batches all the valid ones before the invalid one and sends it out. + // - when trying to batch next, it finds the invalid one at the header. It discards and continue with next batch + self.removeStoredEvents(num: 1) + self.processNextBatch(failureCount: failureCount) + return + } + + self.sendBatch(event: batchEvent, numEvents: numEvents) { success in + if success { + self.removeStoredEvents(num: numEvents) + self.processNextBatch(failureCount: 0) + } else { + // Retry with backoff + let attempt = failureCount + 1 + if attempt < DefaultValues.maxFailureCount { + let delay = self.calculateRetryDelay(attempt: attempt) + self.queueLock.asyncAfter(deadline: .now() + delay) { + self.processNextBatch(failureCount: attempt) } - // our send is done. - self.notify.leave() - + } else { + self.logger.e(.eventSendRetyFailed(attempt)) + self.finishFlush() } - // wait for send - self.notify.wait() } } } + private func sendBatch(event: EventForDispatch, numEvents: Int, completion: @escaping (Bool) -> Void) { + self.sendEvent(event: event) { result in + switch result { + case .success: + completion(true) + case .failure(let error): + self.logger.e(error.reason) + completion(false) + } + } + } + + private func finishFlush() { + self.isFlushing = false + self.notify.leave() + } + + private func removeStoredEvents(num: Int) { + if let removedItem = self.eventQueue.removeFirstItems(count: num), removedItem.count > 0 { + self.logger.d({ "Removed \(num) event(s) from queue starting with \(removedItem.first!)" }) + } else { + self.logger.e("Failed to remove \(num) event(s) from queue") + } + } + + /// Calculate retry delay using exponential backoff + /// - Parameter attempt: Current attempt number (1, 2, 3) + /// - Returns: Delay in seconds (200ms, 400ms, 800ms, capped at 1s) + private func calculateRetryDelay(attempt: Int) -> TimeInterval { + let retryStrategy = RetryStrategy(maxRetries: 2, + initialInterval: 0.2, + maxInterval: 1.0) + return retryStrategy.delayForRetry(attempt: attempt) + } + open func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { if self.reachability.shouldBlockNetworkAccess() { @@ -211,7 +258,14 @@ open class DefaultEventDispatcher: BackgroundingCallbacks, OPTEventDispatcher { open func close() { self.flushEvents() + // Ensure flush async block has started self.queueLock.sync {} + // Wait for the flush to complete with a safety timeout. + // We use a 10-second timeout to prevent the app from hanging indefinitely during shutdown. + // If the flush takes longer (e.g. due to slow network or large queue), we proceed to close + // to avoid the OS watchdog killing the app for blocking the main thread for too long. + // This ensures a "best effort" flush while prioritizing a safe and graceful exit. + _ = self.notify.wait(timeout: .now() + 10.0) } } diff --git a/Sources/Extensions/ArrayEventForDispatch+Extension.swift b/Sources/Extensions/ArrayEventForDispatch+Extension.swift index 17855f22..d662fb28 100644 --- a/Sources/Extensions/ArrayEventForDispatch+Extension.swift +++ b/Sources/Extensions/ArrayEventForDispatch+Extension.swift @@ -98,7 +98,7 @@ extension Array where Element == EventForDispatch { // no batched event since the first event is invalid. notify so that it can be removed. return (1, nil) } - + return (eventsBatched.count, makeBatchEvent(base: eventsBatched.first!, visitors: visitors, url: url)) } @@ -112,7 +112,7 @@ extension Array where Element == EventForDispatch { anonymizeIP: base.anonymizeIP, enrichDecisions: true, region: base.region) - + guard let data = try? JSONEncoder().encode(batchEvent) else { return nil } diff --git a/Sources/ODP/OdpConfig.swift b/Sources/ODP/OdpConfig.swift index e0bb54f7..66ac3eb1 100644 --- a/Sources/ODP/OdpConfig.swift +++ b/Sources/ODP/OdpConfig.swift @@ -48,7 +48,7 @@ class OdpConfig { // disable future event queueing if datafile has no ODP integrations. self.odpServiceIntegrated = .notIntegrated } - + if self.apiKey == apiKey, self.apiHost == apiHost, self.segmentsToCheck == segmentsToCheck { return false } else { diff --git a/Sources/ODP/OdpEventManager.swift b/Sources/ODP/OdpEventManager.swift index 9f22323d..d136c6ea 100644 --- a/Sources/ODP/OdpEventManager.swift +++ b/Sources/ODP/OdpEventManager.swift @@ -21,14 +21,19 @@ open class OdpEventManager { var apiMgr: OdpEventApiManager var maxQueueSize = 100 - let maxBatchEvents = 10 + var maxBatchEvents = 10 let maxFailureCount = 3 let queueLock: DispatchQueue let eventQueue: DataStoreQueueStackImpl - + let reachability = NetworkReachability(maxContiguousFails: 1) let logger = OPTLoggerFactory.getLogger() + // sync group used to ensure that the flushEvents is synchronous for close() + let notify = DispatchGroup() + // track if flush is currently in progress + private var isFlushing = false + /// OdpEventManager init /// - Parameters: /// - sdkKey: datafile sdkKey @@ -123,74 +128,123 @@ open class OdpEventManager { reset() return } - + guard let odpApiKey = odpConfig.apiKey, let odpApiHost = odpConfig.apiHost else { return } - + // check if network is down to avoid that all existing events in the queue get discarded when network is down - if reachability.shouldBlockNetworkAccess() { + guard !reachability.shouldBlockNetworkAccess() else { logger.e(.eventDispatchFailed("NetworkReachability down for ODP events")) return } - + queueLock.async { - func removeStoredEvents(num: Int) { - if let removedItem = self.eventQueue.removeFirstItems(count: num), removedItem.count > 0 { - // avoid event-log-message preparation overheads with closure-logging - self.logger.d({ "ODP: Removed stored \(num) events starting with \(removedItem.first!)" }) - } else { - self.logger.e("ODP: Failed to removed \(num) events") - } - } + guard !self.isFlushing else { return } - // sync group used to ensure that the sendEvent is synchronous. - // used in flushEvents - let sync = DispatchGroup() - var failureCount = 0 + self.isFlushing = true + self.notify.enter() - while let events: [OdpEvent] = self.eventQueue.getFirstItems(count: self.maxBatchEvents) { - let numEvents = events.count - - // multiple auto-retries are disabled for now - // - this may be too much since they'll be retried any way when next events arrive. - // - also, no guarantee on success after multiple retries, so it helps minimal with extra complexity. - - var odpError: OptimizelyError? - - sync.enter() // make the send event synchronous. enter our notify - self.apiMgr.sendOdpEvents(apiKey: odpApiKey, - apiHost: odpApiHost, - events: events) { error in - if let error = error { - self.logger.e(error.reason) + self.processNextBatch(failureCount: 0, apiKey: odpApiKey, apiHost: odpApiHost) + } + } + + private func processNextBatch(failureCount: Int, apiKey: String, apiHost: String) { + // Global failure counter across all batches in this flush + if failureCount >= self.maxFailureCount { + self.logger.e(.odpEventSendRetyFailed(failureCount)) + self.finishFlush() + return + } + + // Check reachability + if self.reachability.shouldBlockNetworkAccess() { + self.logger.e("NetworkReachability down for ODP events") + self.finishFlush() + return + } + + guard let events: [OdpEvent] = self.eventQueue.getFirstItems(count: self.maxBatchEvents) else { + self.finishFlush() + return + } + + let numEvents = events.count + + guard numEvents > 0 else { + self.finishFlush() + return + } + + self.sendBatch(events: events, apiKey: apiKey, apiHost: apiHost) { success, isRecoverable in + if success { + self.removeStoredEvents(num: numEvents) + self.processNextBatch(failureCount: 0, apiKey: apiKey, apiHost: apiHost) + } else { + if !isRecoverable { + // Non-recoverable error - discard and continue + self.removeStoredEvents(num: numEvents) + self.processNextBatch(failureCount: 0, apiKey: apiKey, apiHost: apiHost) + } else { + // Recoverable error - retry with backoff + let attempt = failureCount + 1 + if attempt < self.maxFailureCount { + let delay = self.calculateRetryDelay(attempt: attempt) + self.queueLock.asyncAfter(deadline: .now() + delay) { + self.processNextBatch(failureCount: attempt, apiKey: apiKey, apiHost: apiHost) + } + } else { + self.logger.e(.odpEventSendRetyFailed(attempt)) + self.finishFlush() } - - odpError = error - sync.leave() // our send is done. } - sync.wait() // wait for send completed + } + } + } + + private func sendBatch(events: [OdpEvent], apiKey: String, apiHost: String, completion: @escaping (Bool, Bool) -> Void) { + self.apiMgr.sendOdpEvents(apiKey: apiKey, + apiHost: apiHost, + events: events) { error in + if let error = error { + self.logger.e(error.reason) - // retry only for recoverable errors (connection failures or 5xx server errors) - if let error = odpError, case .odpEventFailed(_, let canRetry) = error, canRetry { - failureCount += 1 - - if failureCount >= self.maxFailureCount { - self.logger.e(.odpEventSendRetyFailed(failureCount)) - failureCount = 0 - } - } else { // success or non-recoverable errors - failureCount = 0 - } - - if failureCount == 0 { - removeStoredEvents(num: numEvents) + var isRecoverable = true + if case .odpEventFailed(_, let canRetry) = error { + isRecoverable = canRetry } - self.reachability.updateNumContiguousFails(isError: odpError != nil) + self.reachability.updateNumContiguousFails(isError: true) + completion(false, isRecoverable) + } else { + self.reachability.updateNumContiguousFails(isError: false) + completion(true, true) } } } + + private func finishFlush() { + self.isFlushing = false + self.notify.leave() + } + + private func removeStoredEvents(num: Int) { + if let removedItem = self.eventQueue.removeFirstItems(count: num), removedItem.count > 0 { + self.logger.d({ "ODP: Removed \(num) event(s) from queue starting with \(removedItem.first!)" }) + } else { + self.logger.e("ODP: Failed to remove \(num) event(s) from queue") + } + } + + /// Calculate retry delay using exponential backoff + /// - Parameter attempt: Current attempt number (1, 2, 3) + /// - Returns: Delay in seconds (200ms, 400ms, 800ms, capped at 1s) + private func calculateRetryDelay(attempt: Int) -> TimeInterval { + let retryStrategy = RetryStrategy(maxRetries: 2, + initialInterval: 0.2, + maxInterval: 1.0) + return retryStrategy.delayForRetry(attempt: attempt) + } func reset() { _ = eventQueue.removeFirstItems(count: self.maxQueueSize) diff --git a/Sources/Utils/RetryStrategy.swift b/Sources/Utils/RetryStrategy.swift new file mode 100644 index 00000000..525f3731 --- /dev/null +++ b/Sources/Utils/RetryStrategy.swift @@ -0,0 +1,60 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Retry strategy with exponential backoff for event dispatching +class RetryStrategy { + let maxRetries: Int + let initialInterval: TimeInterval + let maxInterval: TimeInterval + + /// Initialize RetryStrategy + /// - Parameters: + /// - maxRetries: Maximum number of retry attempts (default: 2, for total 3 attempts) + /// - initialInterval: Initial retry interval in seconds (default: 0.2 seconds / 200ms) + /// - maxInterval: Maximum retry interval cap in seconds (default: 1.0 second) + init(maxRetries: Int = 2, + initialInterval: TimeInterval = 0.2, + maxInterval: TimeInterval = 1.0) { + self.maxRetries = maxRetries + self.initialInterval = initialInterval + self.maxInterval = maxInterval + } + + /// Calculate delay for a given retry attempt using exponential backoff + /// Formula: min(initialInterval * 2^attempt, maxInterval) + /// - Parameter attempt: The retry attempt number (0-based) + /// - Returns: Delay in seconds + /// Example: For initialInterval=0.2, maxInterval=1.0 + /// - attempt 0: 0.2s (200ms) + /// - attempt 1: 0.4s (400ms) + /// - attempt 2: 0.8s (800ms) + /// - attempt 3+: 1.0s (capped at maxInterval) + func delayForRetry(attempt: Int) -> TimeInterval { + guard attempt > 0 else { return 0 } + + let delay = initialInterval * pow(2.0, Double(attempt - 1)) + return min(delay, maxInterval) + } + + /// Check if should retry based on current attempt count + /// - Parameter currentAttempt: Current attempt number (0-based) + /// - Returns: true if should continue retrying, false otherwise + func shouldRetry(currentAttempt: Int) -> Bool { + return currentAttempt <= maxRetries + } +} diff --git a/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift b/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift index c5d0da8c..a03c58b1 100644 --- a/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift +++ b/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift @@ -462,9 +462,11 @@ extension EventDispatcherTests_Batch { eventDispatcher.close() - let maxFailureCount = 3 // DefaultEventDispatcher.maxFailureCount - - XCTAssertEqual(eventDispatcher.sendRequestedEvents.count, maxFailureCount, "repeated the same request several times before giveup") + // With async implementation, we try 3 times (1 initial + 2 retries) + // The circuit breaker stops after 3 consecutive failures. + let expectedSendsOnFailure = 3 + + XCTAssertEqual(eventDispatcher.sendRequestedEvents.count, expectedSendsOnFailure, "repeated the same request several times before giveup") let batch = eventDispatcher.sendRequestedEvents[0] let batchedEvents = try! JSONDecoder().decode(BatchEvent.self, from: batch.body) @@ -487,8 +489,9 @@ extension EventDispatcherTests_Batch { // assume flushEvents called again on next timer fire eventDispatcher.close() - XCTAssertEqual(eventDispatcher.sendRequestedEvents.count, maxFailureCount + 1, "only one more since succeeded") - XCTAssertEqual(eventDispatcher.sendRequestedEvents[3], eventDispatcher.sendRequestedEvents[0]) + // After error is removed, batch succeeds on first attempt: 3 + 1 = 4 total sends + XCTAssertEqual(eventDispatcher.sendRequestedEvents.count, expectedSendsOnFailure + 1, "only one more since succeeded") + XCTAssertEqual(eventDispatcher.sendRequestedEvents[expectedSendsOnFailure], eventDispatcher.sendRequestedEvents[0]) XCTAssertEqual(eventDispatcher.eventQueue.count, 0, "all expected to get transmitted successfully") } @@ -546,7 +549,7 @@ extension EventDispatcherTests_Batch { (self.kUrlB, self.batchEventB), (self.kUrlC, self.batchEventC)]) - eventDispatcher.queueLock.sync {} + eventDispatcher.close() continueAfterFailure = false // stop on XCTAssertEqual failure instead of array out-of-bound exception XCTAssertEqual(eventDispatcher.sendRequestedEvents.count, 3) @@ -925,12 +928,20 @@ extension EventDispatcherTests_Batch { extension EventDispatcherTests_Batch { func makeEventForDispatch(url: String, event: BatchEvent) -> EventForDispatch { - let data = try! JSONEncoder().encode(event) + let encoder = JSONEncoder() +// if #available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) { +// encoder.outputFormatting = .sortedKeys +// } + let data = try! encoder.encode(event) return EventForDispatch(url: URL(string: url), body: data) } - + func makeInvalidEventForDispatchWithNilUrl() -> EventForDispatch { - let data = try! JSONEncoder().encode(batchEventA) + let encoder = JSONEncoder() +// if #available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) { +// encoder.outputFormatting = .sortedKeys +// } + let data = try! encoder.encode(batchEventA) return EventForDispatch(url: nil, body: data) } diff --git a/Tests/OptimizelyTests-Common/EventDispatcherRetryTests.swift b/Tests/OptimizelyTests-Common/EventDispatcherRetryTests.swift new file mode 100644 index 00000000..a79c95a6 --- /dev/null +++ b/Tests/OptimizelyTests-Common/EventDispatcherRetryTests.swift @@ -0,0 +1,389 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class EventDispatcherRetryTests: XCTestCase { + + var eventDispatcher: DefaultEventDispatcher? + + override func setUp() { + OTUtils.createDocumentDirectoryIfNotAvailable() + } + + override func tearDown() { + OTUtils.clearAllEventQueues() + eventDispatcher = nil + } + + // MARK: - Basic Retry Tests + + func testRetry_SuccessOnFirstAttempt() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + completionHandler(.success(Data())) + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + dispatcher.flushEvents() + dispatcher.queueLock.sync {} + + // Should succeed on first attempt, no retries needed + XCTAssertEqual(dispatcher.sendCount, 1) + XCTAssertEqual(dispatcher.eventQueue.count, 0) + } + + func testRetry_SuccessOnSecondAttempt() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + var sendTimes: [Date] = [] + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + sendTimes.append(Date()) + + if sendCount == 1 { + completionHandler(.failure(.eventDispatchFailed("Network error"))) + } else { + completionHandler(.success(Data())) + } + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + let startTime = Date() + dispatcher.flushEvents() + + // Wait for async retry to complete + let expectation = XCTestExpectation(description: "Retry completes") + dispatcher.queueLock.asyncAfter(deadline: .now() + 0.5) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Should have 2 send attempts (1 initial + 1 retry) + XCTAssertEqual(dispatcher.sendCount, 2) + XCTAssertEqual(dispatcher.eventQueue.count, 0) + + // Verify delay between attempts is approximately 200ms + if dispatcher.sendTimes.count >= 2 { + let delayBetweenAttempts = dispatcher.sendTimes[1].timeIntervalSince(dispatcher.sendTimes[0]) + XCTAssertGreaterThan(delayBetweenAttempts, 0.18, "Delay should be at least 180ms") + XCTAssertLessThan(delayBetweenAttempts, 0.25, "Delay should be less than 250ms") + } + } + + func testRetry_SuccessOnThirdAttempt() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + var sendTimes: [Date] = [] + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + sendTimes.append(Date()) + + if sendCount < 3 { + completionHandler(.failure(.eventDispatchFailed("Network error"))) + } else { + completionHandler(.success(Data())) + } + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + dispatcher.flushEvents() + + // Wait for retries to complete (200ms + 400ms + processing time) + let expectation = XCTestExpectation(description: "Retries complete") + dispatcher.queueLock.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + // Should have 3 send attempts (1 initial + 2 retries) + XCTAssertEqual(dispatcher.sendCount, 3) + XCTAssertEqual(dispatcher.eventQueue.count, 0) + + // Verify exponential backoff delays + if dispatcher.sendTimes.count >= 3 { + let delay1 = dispatcher.sendTimes[1].timeIntervalSince(dispatcher.sendTimes[0]) + let delay2 = dispatcher.sendTimes[2].timeIntervalSince(dispatcher.sendTimes[1]) + + // First retry: ~200ms + XCTAssertGreaterThan(delay1, 0.18) + XCTAssertLessThan(delay1, 0.25) + + // Second retry: ~400ms + XCTAssertGreaterThan(delay2, 0.38) + XCTAssertLessThan(delay2, 0.45) + } + } + + func testRetry_AllAttemptsExhausted() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + // Always fail + completionHandler(.failure(.eventDispatchFailed("Network error"))) + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + dispatcher.flushEvents() + + // Wait for all retries to exhaust + let expectation = XCTestExpectation(description: "All retries exhausted") + dispatcher.queueLock.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + // Should have 3 send attempts (1 initial + 2 retries) + XCTAssertEqual(dispatcher.sendCount, 3) + + // Event should remain in queue after max retries exhausted + XCTAssertEqual(dispatcher.eventQueue.count, 1) + } + + // MARK: - Edge Cases + + func testRetry_InvalidEventDiscarded() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + completionHandler(.success(Data())) + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + + // Add an invalid event (empty body that can't be batched) + // The batching logic will return nil for invalid events + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + dispatcher.flushEvents() + dispatcher.queueLock.sync {} + + // Invalid events should be discarded without retry + XCTAssertEqual(dispatcher.eventQueue.count, 0) + } + + func testRetry_MultipleBatches() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + var failFirstBatch = true + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + + // First batch fails once then succeeds + if failFirstBatch && sendCount == 1 { + failFirstBatch = false + completionHandler(.failure(.eventDispatchFailed("Network error"))) + } else { + completionHandler(.success(Data())) + } + } + } + + let dispatcher = TestEventDispatcher(batchSize: 1, timerInterval: 0) + + // Add 3 events + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + dispatcher.flushEvents() + + // Wait for all batches to process + let expectation = XCTestExpectation(description: "All batches processed") + dispatcher.queueLock.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + // Should have processed all events (1 retry for first batch + 2 immediate successes) + XCTAssertEqual(dispatcher.sendCount, 4) + XCTAssertEqual(dispatcher.eventQueue.count, 0) + } + + func testRetry_NetworkReachabilityDown() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + completionHandler(.failure(.eventDispatchFailed("Network down"))) + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + // Simulate network down by triggering multiple failures + for _ in 0..<3 { + dispatcher.reachability.updateNumContiguousFails(isError: true) + } + + // Also need to set isConnected to false for shouldBlockNetworkAccess to return true + dispatcher.reachability.isConnected = false + + XCTAssertTrue(dispatcher.reachability.shouldBlockNetworkAccess()) + + dispatcher.flushEvents() + dispatcher.queueLock.sync {} + + // When network is down, sendEvent returns early + // No retries should happen + XCTAssertEqual(dispatcher.sendCount, 0) + } + + func testRetry_QueueNotBlockedDuringRetry() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + completionHandler(.failure(.eventDispatchFailed("Network error"))) + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + dispatcher.flushEvents() + + // While retry is pending, add another event + let addEventExpectation = XCTestExpectation(description: "Event added during retry") + dispatcher.queueLock.asyncAfter(deadline: .now() + 0.1) { + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + addEventExpectation.fulfill() + } + + wait(for: [addEventExpectation], timeout: 0.5) + + // Queue should accept new events during retry + XCTAssertGreaterThanOrEqual(dispatcher.eventQueue.count, 1) + } + + func testRetry_ConcurrentFlushCalls() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + let sendLock = DispatchQueue(label: "sendLock") + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendLock.sync { + sendCount += 1 + } + completionHandler(.success(Data())) + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + + // Add multiple events + for _ in 0..<5 { + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + } + + // Call flush multiple times concurrently + let queue = DispatchQueue(label: "test", attributes: .concurrent) + for _ in 0..<3 { + queue.async { + dispatcher.flushEvents() + } + } + + // Wait for all flushes to complete + let expectation = XCTestExpectation(description: "Flushes complete") + dispatcher.queueLock.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + // All events should be processed + XCTAssertEqual(dispatcher.eventQueue.count, 0) + } + + // MARK: - Timing Tests + + func testRetry_DelayTiming() { + class TestEventDispatcher: DefaultEventDispatcher { + var sendCount = 0 + var sendTimes: [Date] = [] + + override func sendEvent(event: EventForDispatch, completionHandler: @escaping DispatchCompletionHandler) { + sendCount += 1 + sendTimes.append(Date()) + + if sendCount < 3 { + completionHandler(.failure(.eventDispatchFailed("Network error"))) + } else { + completionHandler(.success(Data())) + } + } + } + + let dispatcher = TestEventDispatcher(timerInterval: 0) + dispatcher.eventQueue.save(item: EventForDispatch(body: Data())) + + let startTime = Date() + dispatcher.flushEvents() + + // Wait for all retries to complete + let expectation = XCTestExpectation(description: "All retries complete") + dispatcher.queueLock.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + // Verify timing sequence + XCTAssertEqual(dispatcher.sendTimes.count, 3) + + if dispatcher.sendTimes.count >= 3 { + let time0 = dispatcher.sendTimes[0].timeIntervalSince(startTime) + let time1 = dispatcher.sendTimes[1].timeIntervalSince(startTime) + let time2 = dispatcher.sendTimes[2].timeIntervalSince(startTime) + + // First attempt: immediate + XCTAssertLessThan(time0, 0.05) + + // Second attempt: ~200ms after start + XCTAssertGreaterThan(time1, 0.18) + XCTAssertLessThan(time1, 0.25) + + // Third attempt: ~600ms after start (200ms + 400ms) + XCTAssertGreaterThan(time2, 0.58) + XCTAssertLessThan(time2, 0.7) + } + } +} diff --git a/Tests/OptimizelyTests-Common/EventDispatcherTests.swift b/Tests/OptimizelyTests-Common/EventDispatcherTests.swift index 37e991ce..f68ad16d 100644 --- a/Tests/OptimizelyTests-Common/EventDispatcherTests.swift +++ b/Tests/OptimizelyTests-Common/EventDispatcherTests.swift @@ -29,6 +29,11 @@ class EventDispatcherTests: XCTestCase { XCTAssertEqual(MockUrlSession.validSessions, 0, "all MockUrlSession must be invalidated") } + func waitForFlush() { + eventDispatcher?.queueLock.sync {} + _ = eventDispatcher?.notify.wait(timeout: .now() + 10.0) + } + func testDefaultDispatcher() { eventDispatcher = DefaultEventDispatcher(timerInterval: 10) let pEventD: OPTEventDispatcher = eventDispatcher! @@ -43,8 +48,7 @@ class EventDispatcherTests: XCTestCase { XCTAssert(eventDispatcher?.eventQueue.count == 1) eventDispatcher?.flushEvents() - - eventDispatcher?.queueLock.sync {} + waitForFlush() XCTAssert(eventDispatcher?.eventQueue.count == 0) @@ -86,15 +90,15 @@ class EventDispatcherTests: XCTestCase { } pEventD.flushEvents() - wait() + waitForFlush() pEventD.dispatchEvent(event: EventForDispatch(body: Data()), completionHandler: nil) - wait() + waitForFlush() XCTAssert(eventDispatcher?.eventQueue.count == 1) eventDispatcher?.flushEvents() - wait() + waitForFlush() XCTAssert(eventDispatcher?.eventQueue.count == 0) @@ -111,15 +115,15 @@ class EventDispatcherTests: XCTestCase { } pEventD.flushEvents() - wait() + waitForFlush() pEventD.dispatchEvent(event: EventForDispatch(body: Data()), completionHandler: nil) - wait() + waitForFlush() XCTAssert(eventDispatcher?.eventQueue.count == 1) eventDispatcher?.flushEvents() - wait() + waitForFlush() XCTAssert(eventDispatcher?.eventQueue.count == 0) @@ -136,15 +140,15 @@ class EventDispatcherTests: XCTestCase { } pEventD.flushEvents() - wait() + waitForFlush() pEventD.dispatchEvent(event: EventForDispatch(body: Data()), completionHandler: nil) - wait() + waitForFlush() XCTAssert(eventDispatcher?.eventQueue.count == 1) eventDispatcher?.flushEvents() - wait() + waitForFlush() XCTAssert(eventDispatcher?.eventQueue.count == 0) @@ -198,11 +202,11 @@ class EventDispatcherTests: XCTestCase { eventDispatcher = DefaultEventDispatcher(timerInterval: 1) eventDispatcher?.flushEvents() - eventDispatcher?.queueLock.sync {} + waitForFlush() eventDispatcher?.dispatchEvent(event: EventForDispatch(body: Data()), completionHandler: nil) - eventDispatcher?.queueLock.sync {} + waitForFlush() eventDispatcher?.applicationDidBecomeActive() eventDispatcher?.applicationDidEnterBackground() @@ -298,6 +302,7 @@ class EventDispatcherTests: XCTestCase { XCTAssert(dispatcher.eventQueue.count == 2) dispatcher.flushEvents() dispatcher.queueLock.sync {} + _ = dispatcher.notify.wait(timeout: .now() + 10.0) XCTAssert(dispatcher.eventQueue.count == 0) } diff --git a/Tests/OptimizelyTests-Common/OdpEventManagerRetryTests.swift b/Tests/OptimizelyTests-Common/OdpEventManagerRetryTests.swift new file mode 100644 index 00000000..32eeacb0 --- /dev/null +++ b/Tests/OptimizelyTests-Common/OdpEventManagerRetryTests.swift @@ -0,0 +1,417 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class OdpEventManagerRetryTests: XCTestCase { + var manager: OdpEventManager! + var apiManager: MockOdpEventApiManager! + var odpConfig: OdpConfig! + + override func setUp() { + OTUtils.clearAllEventQueues() + OTUtils.createDocumentDirectoryIfNotAvailable() + + odpConfig = OdpConfig() + + apiManager = MockOdpEventApiManager() + manager = OdpEventManager(sdkKey: "test-sdk-key", apiManager: apiManager) + manager.odpConfig = odpConfig + } + + override func tearDown() { + OTUtils.clearAllEventQueues() + manager = nil + apiManager = nil + } + + /// Wait for all flush operations to complete + func waitForFlush() { + // Ensure flush async block has started + manager.queueLock.sync {} + // Wait for the flush to complete + _ = manager.notify.wait(timeout: .now() + 10.0) + } + + // MARK: - Basic Retry Tests + + func testRetry_RecoverableError_SuccessOnFirstAttempt() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to succeed immediately + apiManager.shouldFail = false + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for flush to complete + waitForFlush() + + // Should succeed on first attempt + XCTAssertEqual(apiManager.sendCount, 1) + XCTAssertEqual(manager.eventQueue.count, 0) + XCTAssertEqual(apiManager.dispatchedBatchEvents.count, 1) + } + + func testRetry_RecoverableError_SuccessAfterRetry() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to fail once then succeed + apiManager.failCount = 1 + apiManager.shouldFail = true + apiManager.recoverable = true + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for retry to complete (200ms delay + processing) + waitForFlush() + + // Should have 2 attempts (1 initial + 1 retry) + XCTAssertEqual(apiManager.sendCount, 2) + XCTAssertEqual(manager.eventQueue.count, 0) + XCTAssertEqual(apiManager.dispatchedBatchEvents.count, 2) + + // Verify delay timing + if apiManager.sendTimes.count >= 2 { + let delay = apiManager.sendTimes[1].timeIntervalSince(apiManager.sendTimes[0]) + XCTAssertGreaterThan(delay, 0.18, "Delay should be at least 180ms") + XCTAssertLessThan(delay, 0.25, "Delay should be less than 250ms") + } + } + + func testRetry_RecoverableError_SuccessAfterTwoRetries() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to fail twice then succeed + apiManager.failCount = 2 + apiManager.shouldFail = true + apiManager.recoverable = true + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for retries to complete (200ms + 400ms + processing) + waitForFlush() + + // Should have 3 attempts (1 initial + 2 retries) + XCTAssertEqual(apiManager.sendCount, 3) + XCTAssertEqual(manager.eventQueue.count, 0) + + // Verify exponential backoff delays + if apiManager.sendTimes.count >= 3 { + let delay1 = apiManager.sendTimes[1].timeIntervalSince(apiManager.sendTimes[0]) + let delay2 = apiManager.sendTimes[2].timeIntervalSince(apiManager.sendTimes[1]) + + // First retry: ~200ms + XCTAssertGreaterThan(delay1, 0.18) + XCTAssertLessThan(delay1, 0.25) + + // Second retry: ~400ms + XCTAssertGreaterThan(delay2, 0.38) + XCTAssertLessThan(delay2, 0.45) + } + } + + func testRetry_RecoverableError_AllAttemptsExhausted() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to always fail with recoverable error + apiManager.failCount = 10 // More than max retries + apiManager.shouldFail = true + apiManager.recoverable = true + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for all retries to exhaust + waitForFlush() + + // With async implementation, we try 3 times (1 initial + 2 retries) + // The circuit breaker stops after 3 consecutive failures. + XCTAssertEqual(apiManager.sendCount, 3) + + // Event should remain in queue after global failure count exhausted + XCTAssertEqual(manager.eventQueue.count, 1) + } + + func testRetry_NonRecoverableError_NoRetry() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to fail with non-recoverable error (4xx) + apiManager.shouldFail = true + apiManager.recoverable = false + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for processing + waitForFlush() + + // Should only have 1 attempt, no retries + XCTAssertEqual(apiManager.sendCount, 1) + + // Event should be removed (non-recoverable error) + XCTAssertEqual(manager.eventQueue.count, 0) + } + + // MARK: - Edge Cases + + func testRetry_MultipleBatches() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Set maxBatchEvents to 1 to make each event a separate batch + manager.maxBatchEvents = 1 + + // First batch fails once then succeeds + apiManager.failCount = 1 + apiManager.shouldFail = true + apiManager.recoverable = true + apiManager.failOnlyFirstBatch = true + + // Add 3 events (each will be a separate batch since maxBatchEvents=1) + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + manager.sendEvent(type: "t2", action: "a2", identifiers: ["id": "2"], data: [:]) + manager.sendEvent(type: "t3", action: "a3", identifiers: ["id": "3"], data: [:]) + + // Wait for all batches to process + waitForFlush() + + // Should process all events: first batch (2 attempts), other batches (1 attempt each) = 4 total + XCTAssertGreaterThanOrEqual(apiManager.sendCount, 4) + XCTAssertEqual(manager.eventQueue.count, 0) + } + + func testRetry_NetworkDown_NoRetry() { + // Simulate network down + for _ in 0..<3 { + manager.reachability.updateNumContiguousFails(isError: true) + } + + // Also need to set isConnected to false for shouldBlockNetworkAccess to return true + manager.reachability.isConnected = false + + XCTAssertTrue(manager.reachability.shouldBlockNetworkAccess()) + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for processing + waitForFlush() + + // When network is down, flush should return early + XCTAssertEqual(apiManager.sendCount, 0) + XCTAssertEqual(manager.eventQueue.count, 1) + } + + func testRetry_ConfigNotReady_NoRetry() { + // Clear ODP config (no API key) and set state to notIntegrated + // This will set eventQueueingAllowed = false, which triggers queue clearing + _ = odpConfig.update(apiKey: nil, apiHost: nil, segmentsToCheck: []) + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for processing + waitForFlush() + + // Should not send when config not ready + XCTAssertEqual(apiManager.sendCount, 0) + XCTAssertEqual(manager.eventQueue.count, 0) // Queue cleared when queueing not allowed + } + + func testRetry_MaxQueueSize_NoRetry() { + // Set very small queue size + manager.maxQueueSize = 1 + + apiManager.shouldFail = true + apiManager.recoverable = true + apiManager.failCount = 10 + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + manager.sendEvent(type: "t2", action: "a2", identifiers: ["id": "2"], data: [:]) + + // Wait for processing + waitForFlush() + + // Should only have 1 event in queue (queue full) + XCTAssertEqual(manager.eventQueue.count, 1) + } + + func testRetry_MixedSuccessFailure() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Set maxBatchEvents to 1 to make each event a separate batch + manager.maxBatchEvents = 1 + + // Configure: fail first 2 batches, succeed on rest + apiManager.failCount = 2 + apiManager.shouldFail = true + apiManager.recoverable = true + apiManager.resetCountOnSuccess = true + + // Add multiple events + for i in 1...5 { + manager.sendEvent(type: "t\(i)", action: "a\(i)", identifiers: ["id": "\(i)"], data: [:]) + } + + // Wait for all to process + waitForFlush() + + // All events should eventually succeed + // Batch 1: fail, fail, success (3 attempts) + // Batch 2: fail, fail, success (3 attempts, counter resets on success) + // Batches 3-5: success (1 attempt each) + // Total: 3 + 3 + 1 + 1 + 1 = 9 attempts + XCTAssertEqual(manager.eventQueue.count, 0) + XCTAssertGreaterThanOrEqual(apiManager.sendCount, 9) + } + + func testRetry_EventsRemovedAfterSuccess() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to fail once then succeed + apiManager.failCount = 1 + apiManager.shouldFail = true + apiManager.recoverable = true + + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + manager.sendEvent(type: "t2", action: "a2", identifiers: ["id": "2"], data: [:]) + + // Wait for processing + waitForFlush() + + // All events should be removed after successful retries + XCTAssertEqual(manager.eventQueue.count, 0) + } + + // MARK: - Timing Tests + + func testRetry_DelaySequence() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to fail twice then succeed + apiManager.failCount = 2 + apiManager.shouldFail = true + apiManager.recoverable = true + + let startTime = Date() + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + + // Wait for all retries + waitForFlush() + + // Verify timing sequence + XCTAssertEqual(apiManager.sendCount, 3) + + if apiManager.sendTimes.count >= 3 { + let time0 = apiManager.sendTimes[0].timeIntervalSince(startTime) + let time1 = apiManager.sendTimes[1].timeIntervalSince(startTime) + let time2 = apiManager.sendTimes[2].timeIntervalSince(startTime) + + // First attempt: immediate + XCTAssertLessThan(time0, 0.1) + + // Second attempt: ~200ms + XCTAssertGreaterThan(time1, 0.18) + XCTAssertLessThan(time1, 0.3) + + // Third attempt: ~600ms (200ms + 400ms) + XCTAssertGreaterThan(time2, 0.58) + XCTAssertLessThan(time2, 0.75) + } + } + + func testRetry_ResetOnSuccess() { + // Configure ODP + _ = odpConfig.update(apiKey: "test-api-key", apiHost: "test-api-host", segmentsToCheck: []) + + // Configure to fail once on each batch + apiManager.failCount = 1 + apiManager.shouldFail = true + apiManager.recoverable = true + apiManager.resetCountOnSuccess = true + apiManager.failOnlyFirstAttempt = true + + // Add multiple events + manager.sendEvent(type: "t1", action: "a1", identifiers: ["id": "1"], data: [:]) + manager.sendEvent(type: "t2", action: "a2", identifiers: ["id": "2"], data: [:]) + + // Wait for processing + waitForFlush() + + // Each batch should retry once then succeed + XCTAssertEqual(manager.eventQueue.count, 0) + } + + // MARK: - Mock API Manager + + class MockOdpEventApiManager: OdpEventApiManager { + var sendCount = 0 + var sendTimes: [Date] = [] + var dispatchedBatchEvents = [[OdpEvent]]() + + var shouldFail = false + var recoverable = false + var failCount = 0 + var currentFailCount = 0 + var failOnlyFirstBatch = false + var failOnlyFirstAttempt = false + var resetCountOnSuccess = false + + override func sendOdpEvents(apiKey: String, + apiHost: String, + events: [OdpEvent], + completionHandler: @escaping (OptimizelyError?) -> Void) { + sendCount += 1 + sendTimes.append(Date()) + dispatchedBatchEvents.append(events) + + DispatchQueue.global().async { + if self.shouldFail { + // Check if should fail this attempt + let shouldFailThisAttempt: Bool + + if self.failOnlyFirstAttempt { + shouldFailThisAttempt = (self.sendCount == 1) + } else if self.failOnlyFirstBatch { + shouldFailThisAttempt = (self.dispatchedBatchEvents.count == 1) && (self.currentFailCount < self.failCount) + } else { + shouldFailThisAttempt = self.currentFailCount < self.failCount + } + + if shouldFailThisAttempt { + self.currentFailCount += 1 + let error = OptimizelyError.odpEventFailed( + self.recoverable ? "Network error" : "403 Forbidden", + self.recoverable + ) + completionHandler(error) + return + } + } + + // Success + if self.resetCountOnSuccess { + self.currentFailCount = 0 + } + completionHandler(nil) + } + } + } +} diff --git a/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift b/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift index d06562b8..2f989378 100644 --- a/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift +++ b/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift @@ -49,6 +49,12 @@ class OdpEventManagerTests: XCTestCase { manager.maxQueueSize = originalMaxQueueSize } + func waitForFlush(_ manager: OdpEventManager? = nil) { + let mgr = manager ?? self.manager! + mgr.queueLock.sync {} + _ = mgr.notify.wait(timeout: .now() + 10.0) + } + // MARK: - save and restore events func testSaveAndRestoreEvents() { @@ -77,7 +83,7 @@ class OdpEventManagerTests: XCTestCase { data: customData) XCTAssertEqual(1, manager.eventQueue.count) - sleep(1) + waitForFlush() XCTAssertEqual(1, manager.eventQueue.count, "not flushed since apiKey is not ready") let evt = manager.eventQueue.getFirstItem()! @@ -138,7 +144,7 @@ class OdpEventManagerTests: XCTestCase { data: customData) XCTAssertEqual(1, manager.eventQueue.count) - sleep(1) + waitForFlush() XCTAssertEqual(0, manager.eventQueue.count, "flushed since apiKey is ready") } @@ -154,7 +160,7 @@ class OdpEventManagerTests: XCTestCase { manager.sendEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) XCTAssertEqual(3, manager.eventQueue.count) - sleep(1) + waitForFlush() XCTAssertEqual(3, manager.eventQueue.count, "not flushed since apiKey is not ready") // apiKey is available in datafile (so ODP integrated) @@ -163,7 +169,7 @@ class OdpEventManagerTests: XCTestCase { XCTAssertTrue(manager.odpConfig.eventQueueingAllowed, "datafile ready and odp integrated. event queueing is allowed.") manager.flush() // need manual flush here since OdpManager is not connected - sleep(1) + waitForFlush() XCTAssertEqual(0, manager.eventQueue.count) XCTAssertEqual(3, apiManager.totalDispatchedEvents) apiManager.dispatchedBatchEvents.removeAll() @@ -174,7 +180,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) // each of these will try to flush XCTAssertEqual(2, manager.eventQueue.count) - sleep(1) + waitForFlush() XCTAssertEqual(0, manager.eventQueue.count, "auto flushed since apiKey is ready") XCTAssertEqual(2, apiManager.totalDispatchedEvents) } @@ -189,7 +195,7 @@ class OdpEventManagerTests: XCTestCase { manager.sendEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) XCTAssertEqual(3, manager.eventQueue.count) - sleep(1) + waitForFlush() XCTAssertEqual(3, manager.eventQueue.count, "not flushed since apiKey is not ready") // apiKey is not available in datafile (so ODP not integrated) @@ -205,7 +211,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) // each of these will try to flush XCTAssertEqual(0, manager.eventQueue.count) - sleep(1) + waitForFlush() XCTAssertEqual(0, manager.eventQueue.count, "all news events are discarded since event queueing not allowed") XCTAssertEqual(0, apiManager.totalDispatchedEvents, "all events discarded") } @@ -219,7 +225,7 @@ class OdpEventManagerTests: XCTestCase { manager.identifyUser(vuid: "v1", userId: "u1") manager.sendEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) - sleep(1) + waitForFlush() XCTAssertEqual(2, manager.eventQueue.count, "an event discarded since queue overflowed") // apiKey is available in datafile (so ODP integrated) @@ -228,7 +234,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) // each of these will try to flush - sleep(1) + waitForFlush() XCTAssertEqual(0, manager.eventQueue.count, "flush is called even when an event is discarded because queue is overflowed") XCTAssertEqual(2, apiManager.totalDispatchedEvents) } @@ -241,7 +247,7 @@ class OdpEventManagerTests: XCTestCase { _ = odpConfig.update(apiKey: "valid", apiHost: "host", segmentsToCheck: []) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(1, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(1, apiManager.dispatchedBatchEvents[0].count) @@ -261,7 +267,7 @@ class OdpEventManagerTests: XCTestCase { _ = odpConfig.update(apiKey: "valid", apiHost: "host", segmentsToCheck: []) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(1, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(3, apiManager.dispatchedBatchEvents[0].count) @@ -277,7 +283,7 @@ class OdpEventManagerTests: XCTestCase { _ = odpConfig.update(apiKey: "valid", apiHost: "host", segmentsToCheck: []) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(2, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(10, apiManager.dispatchedBatchEvents[0].count) @@ -288,7 +294,7 @@ class OdpEventManagerTests: XCTestCase { func testFlush_emptyQueue() { _ = odpConfig.update(apiKey: "valid", apiHost: "host", segmentsToCheck: []) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(0, apiManager.dispatchedBatchEvents.count) } @@ -322,7 +328,7 @@ class OdpEventManagerTests: XCTestCase { _ = odpConfig1.update(apiKey: "valid", apiHost: "host", segmentsToCheck: []) manager1.flush() - sleep(1) + waitForFlush(manager1) XCTAssertEqual(1, apiManager1.dispatchedBatchEvents.count) XCTAssertEqual(2, apiManager1.dispatchedBatchEvents[0].count) @@ -330,7 +336,7 @@ class OdpEventManagerTests: XCTestCase { _ = odpConfig2.update(apiKey: "valid", apiHost: "host", segmentsToCheck: []) manager2.flush() - sleep(1) + waitForFlush(manager2) XCTAssertEqual(1, apiManager1.dispatchedBatchEvents.count) XCTAssertEqual(2, apiManager1.dispatchedBatchEvents[0].count) @@ -345,7 +351,7 @@ class OdpEventManagerTests: XCTestCase { _ = odpConfig.update(apiKey: "invalid-key-no-retry", apiHost: "host", segmentsToCheck: []) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(1, apiManager.dispatchedBatchEvents.count, "should not be retried for 4xx error") XCTAssertEqual(0, manager.eventQueue.count, "the events should be discarded") @@ -358,7 +364,7 @@ class OdpEventManagerTests: XCTestCase { apiManager.maxCountWithErrorResponse = failCnt manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(failCnt + 1, apiManager.dispatchedBatchEvents.count, "should be retried max for 5xx error") XCTAssertEqual(0, manager.eventQueue.count, "the events should be removed after success") @@ -371,7 +377,7 @@ class OdpEventManagerTests: XCTestCase { apiManager.maxCountWithErrorResponse = failCnt manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(failCnt + 1, apiManager.dispatchedBatchEvents.count, "should be retried for 5xx error") XCTAssertEqual(0, manager.eventQueue.count, "the events should be removed after success") @@ -384,10 +390,10 @@ class OdpEventManagerTests: XCTestCase { apiManager.maxCountWithErrorResponse = failCnt manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(3, apiManager.dispatchedBatchEvents.count, "should be retried max 3 times for 5xx error") - XCTAssertEqual(0, manager.eventQueue.count, "the events should be discarded after 3 retries") + XCTAssertEqual(1, manager.eventQueue.count, "the events should be kept in queue after 3 retries") } // MARK: - reachability @@ -398,7 +404,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(1, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(0, manager.eventQueue.count) @@ -410,7 +416,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(2, apiManager.dispatchedBatchEvents.count, "should not block event dispatch") XCTAssertEqual(0, manager.eventQueue.count) @@ -423,10 +429,10 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(3, apiManager.dispatchedBatchEvents.count, "should be retried max 3 times for 5xx error") - XCTAssertEqual(0, manager.eventQueue.count, "the events should be discarded after max retries") + XCTAssertEqual(1, manager.eventQueue.count, "the events should be kept in queue after max retries") // connected. should not block even if there is a previous error. @@ -435,10 +441,10 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(6, apiManager.dispatchedBatchEvents.count, "should dispatch if connected even if previous event discarded") - XCTAssertEqual(0, manager.eventQueue.count, "the events should be discarded after max retries") + XCTAssertEqual(2, manager.eventQueue.count, "the events should be kept in queue after max retries") XCTAssertFalse(manager.reachability.shouldBlockNetworkAccess()) } @@ -448,7 +454,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(1, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(0, manager.eventQueue.count) @@ -460,7 +466,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(2, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(0, manager.eventQueue.count) @@ -473,10 +479,10 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(3, apiManager.dispatchedBatchEvents.count, "should be retried max 3 times for 5xx error") - XCTAssertEqual(0, manager.eventQueue.count, "the events should be discarded after max retries") + XCTAssertEqual(1, manager.eventQueue.count, "the events should be kept in queue after max retries") // disconnected. should block because there is a previous error and disconnected. @@ -485,10 +491,10 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(3, apiManager.dispatchedBatchEvents.count, "should not dispatch any more when not connected and previous event discarded") - XCTAssertEqual(1, manager.eventQueue.count, "the events should stay in the queue") + XCTAssertEqual(2, manager.eventQueue.count, "the events should stay in the queue") // connected again. should not block any more even if there is a previous error. @@ -499,7 +505,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(4, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(0, manager.eventQueue.count) @@ -512,7 +518,7 @@ class OdpEventManagerTests: XCTestCase { manager.dispatch(event) manager.flush() - sleep(1) + waitForFlush() XCTAssertEqual(5, apiManager.dispatchedBatchEvents.count) XCTAssertEqual(0, manager.eventQueue.count) @@ -547,7 +553,7 @@ class OdpEventManagerTests: XCTestCase { let event = OdpEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) manager.dispatch(event) - sleep(1) + waitForFlush() XCTAssertEqual("test-host", apiManager.receivedApiHost) XCTAssertEqual("test-key", apiManager.receivedApiKey) diff --git a/Tests/OptimizelyTests-Common/OdpSegmentApiManagerTests.swift b/Tests/OptimizelyTests-Common/OdpSegmentApiManagerTests.swift index b9c8f23a..33ba85de 100644 --- a/Tests/OptimizelyTests-Common/OdpSegmentApiManagerTests.swift +++ b/Tests/OptimizelyTests-Common/OdpSegmentApiManagerTests.swift @@ -288,25 +288,33 @@ extension OdpSegmentApiManagerTests { func testLiveOdpGraphQL_defaultParameters_userNotRegistered() { let manager = OdpSegmentApiManager() - + let sem = DispatchSemaphore(value: 0) manager.fetchSegments(apiKey: odpApiKey, apiHost: odpApiHost, userKey: "fs_user_id", userValue: "not-registered-user-1", segmentsToCheck: ["segment-1"]) { segments, error in - if case .invalidSegmentIdentifier = error { - XCTAssert(true) - - // [TODO] ODP server will fix to add this "InvalidSegmentIdentifier" later. - // Until then, use the old error format ("DataFetchingException"). - - } else if case .fetchSegmentsFailed("DataFetchingException") = error { - XCTAssert(true) + // API behavior has changed - now returns empty array instead of error for unregistered users + // Accept both old error response and new empty array response + if let error = error { + if case .invalidSegmentIdentifier = error { + XCTAssert(true) + + // [TODO] ODP server will fix to add this "InvalidSegmentIdentifier" later. + // Until then, use the old error format ("DataFetchingException"). + + } else if case .fetchSegmentsFailed("DataFetchingException") = error { + XCTAssert(true) + } else { + XCTFail("Unexpected error type: \(error)") + } + XCTAssertNil(segments) } else { - XCTFail() + // New API behavior: returns empty array for unregistered users + XCTAssertNotNil(segments) + XCTAssertEqual(segments, [], "Expected empty array for unregistered user") } - XCTAssertNil(segments) sem.signal() } XCTAssertEqual(.success, sem.wait(timeout: .now() + .seconds(30))) diff --git a/Tests/OptimizelyTests-Common/RetryStrategyTests.swift b/Tests/OptimizelyTests-Common/RetryStrategyTests.swift new file mode 100644 index 00000000..0065a719 --- /dev/null +++ b/Tests/OptimizelyTests-Common/RetryStrategyTests.swift @@ -0,0 +1,155 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class RetryStrategyTests: XCTestCase { + + func testDelayCalculation_InitialAttempt() { + let strategy = RetryStrategy() + + // Attempt 0 should have no delay + let delay = strategy.delayForRetry(attempt: 0) + + XCTAssertEqual(delay, 0.0, accuracy: 0.001) + } + + func testDelayCalculation_FirstRetry() { + let strategy = RetryStrategy() + + // First retry (attempt 1): 0.2 * 2^0 = 0.2s (200ms) + let delay = strategy.delayForRetry(attempt: 1) + + XCTAssertEqual(delay, 0.2, accuracy: 0.001) + } + + func testDelayCalculation_SecondRetry() { + let strategy = RetryStrategy() + + // Second retry (attempt 2): 0.2 * 2^1 = 0.4s (400ms) + let delay = strategy.delayForRetry(attempt: 2) + + XCTAssertEqual(delay, 0.4, accuracy: 0.001) + } + + func testDelayCalculation_ThirdRetry() { + let strategy = RetryStrategy() + + // Third retry (attempt 3): 0.2 * 2^2 = 0.8s (800ms) + let delay = strategy.delayForRetry(attempt: 3) + + XCTAssertEqual(delay, 0.8, accuracy: 0.001) + } + + func testDelayCalculation_MaxCapReached() { + let strategy = RetryStrategy() + + // Fourth retry (attempt 4): 0.2 * 2^3 = 1.6s but capped at 1.0s + let delay = strategy.delayForRetry(attempt: 4) + + XCTAssertEqual(delay, 1.0, accuracy: 0.001) + } + + func testDelayCalculation_BeyondMaxCap() { + let strategy = RetryStrategy() + + // Fifth retry (attempt 5): 0.2 * 2^4 = 3.2s but capped at 1.0s + let delay = strategy.delayForRetry(attempt: 5) + + XCTAssertEqual(delay, 1.0, accuracy: 0.001) + } + + func testShouldRetry_WithinLimit() { + let strategy = RetryStrategy(maxRetries: 2) + + XCTAssertTrue(strategy.shouldRetry(currentAttempt: 0)) + XCTAssertTrue(strategy.shouldRetry(currentAttempt: 1)) + XCTAssertTrue(strategy.shouldRetry(currentAttempt: 2)) + } + + func testShouldRetry_AtLimit() { + let strategy = RetryStrategy(maxRetries: 2) + + // At maxRetries, should still allow retry + XCTAssertTrue(strategy.shouldRetry(currentAttempt: 2)) + } + + func testShouldRetry_BeyondLimit() { + let strategy = RetryStrategy(maxRetries: 2) + + // Beyond maxRetries, should not retry + XCTAssertFalse(strategy.shouldRetry(currentAttempt: 3)) + XCTAssertFalse(strategy.shouldRetry(currentAttempt: 4)) + } + + func testCustomConfiguration_LowerInitialInterval() { + let strategy = RetryStrategy(maxRetries: 2, initialInterval: 0.1, maxInterval: 0.5) + + XCTAssertEqual(strategy.delayForRetry(attempt: 1), 0.1, accuracy: 0.001) + XCTAssertEqual(strategy.delayForRetry(attempt: 2), 0.2, accuracy: 0.001) + XCTAssertEqual(strategy.delayForRetry(attempt: 3), 0.4, accuracy: 0.001) + XCTAssertEqual(strategy.delayForRetry(attempt: 4), 0.5, accuracy: 0.001) // capped + } + + func testCustomConfiguration_HigherMaxInterval() { + let strategy = RetryStrategy(maxRetries: 5, initialInterval: 0.5, maxInterval: 5.0) + + XCTAssertEqual(strategy.delayForRetry(attempt: 1), 0.5, accuracy: 0.001) + XCTAssertEqual(strategy.delayForRetry(attempt: 2), 1.0, accuracy: 0.001) + XCTAssertEqual(strategy.delayForRetry(attempt: 3), 2.0, accuracy: 0.001) + XCTAssertEqual(strategy.delayForRetry(attempt: 4), 4.0, accuracy: 0.001) + XCTAssertEqual(strategy.delayForRetry(attempt: 5), 5.0, accuracy: 0.001) // capped + } + + func testExponentialGrowthFormula() { + let strategy = RetryStrategy(maxRetries: 10, initialInterval: 1.0, maxInterval: 100.0) + + // Verify exponential growth: delay = initialInterval * 2^(attempt-1) + XCTAssertEqual(strategy.delayForRetry(attempt: 1), 1.0, accuracy: 0.001) // 1 * 2^0 + XCTAssertEqual(strategy.delayForRetry(attempt: 2), 2.0, accuracy: 0.001) // 1 * 2^1 + XCTAssertEqual(strategy.delayForRetry(attempt: 3), 4.0, accuracy: 0.001) // 1 * 2^2 + XCTAssertEqual(strategy.delayForRetry(attempt: 4), 8.0, accuracy: 0.001) // 1 * 2^3 + XCTAssertEqual(strategy.delayForRetry(attempt: 5), 16.0, accuracy: 0.001) // 1 * 2^4 + XCTAssertEqual(strategy.delayForRetry(attempt: 6), 32.0, accuracy: 0.001) // 1 * 2^5 + XCTAssertEqual(strategy.delayForRetry(attempt: 7), 64.0, accuracy: 0.001) // 1 * 2^6 + XCTAssertEqual(strategy.delayForRetry(attempt: 8), 100.0, accuracy: 0.001) // capped + } + + func testDefaultValues() { + let strategy = RetryStrategy() + + // Verify default configuration + XCTAssertEqual(strategy.maxRetries, 2) + XCTAssertEqual(strategy.initialInterval, 0.2, accuracy: 0.001) + XCTAssertEqual(strategy.maxInterval, 1.0, accuracy: 0.001) + } + + func testZeroAttemptHasNoDelay() { + let strategy = RetryStrategy() + + // Attempt 0 (initial try) should never have a delay + XCTAssertEqual(strategy.delayForRetry(attempt: 0), 0.0) + } + + func testNegativeAttemptHandling() { + let strategy = RetryStrategy() + + // Negative attempts should be handled gracefully + let delay = strategy.delayForRetry(attempt: -1) + + XCTAssertEqual(delay, 0.0) + } +}