From 09a7f82a68ae60824ea12d00138678ffec789a02 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 22 Sep 2026 12:10:21 -0700 Subject: [PATCH 01/12] feat: Add RetryState backoff with normal and extended regimes --- LaunchDarkly.xcodeproj/project.pbxproj | 62 +++++++++------ .../ServiceObjects/RetryState.swift | 68 ++++++++++++++++ .../ServiceObjects/RetryStateSpec.swift | 77 +++++++++++++++++++ 3 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift create mode 100644 LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index b3f97f98..10d0726f 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -15,10 +15,6 @@ 29FE1299280413D4008CC918 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29FE1297280413D4008CC918 /* Util.swift */; }; 29FE129A280413D4008CC918 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29FE1297280413D4008CC918 /* Util.swift */; }; 29FE129B280413D4008CC918 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29FE1297280413D4008CC918 /* Util.swift */; }; - 9AE1D0D12F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; - 9AE1D0D22F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; - 9AE1D0D32F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; - 9AE1D0D42F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; 3D2406142E0D90E000F91253 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406102E0D90E000F91253 /* Plugin.swift */; }; 3D2406152E0D90E000F91253 /* PluginMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406112E0D90E000F91253 /* PluginMetadata.swift */; }; 3D2406162E0D90E000F91253 /* EnvironmentMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D24060F2E0D90E000F91253 /* EnvironmentMetadata.swift */; }; @@ -213,6 +209,11 @@ 83F0A5641FB5F33800550A95 /* LDConfigSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F0A5631FB5F33800550A95 /* LDConfigSpec.swift */; }; 83FEF8DD1F266742001CF12C /* FlagSynchronizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83FEF8DC1F266742001CF12C /* FlagSynchronizer.swift */; }; 83FEF8DF1F2667E4001CF12C /* EventReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83FEF8DE1F2667E4001CF12C /* EventReporter.swift */; }; + 8E38AA8C306308E500C4C163 /* RetryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E38AA8B306308E500C4C163 /* RetryState.swift */; }; + 8E38AA8D306308E500C4C163 /* RetryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E38AA8B306308E500C4C163 /* RetryState.swift */; }; + 8E38AA8E306308E500C4C163 /* RetryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E38AA8B306308E500C4C163 /* RetryState.swift */; }; + 8E38AA8F306308E500C4C163 /* RetryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E38AA8B306308E500C4C163 /* RetryState.swift */; }; + 8E38AA913063090600C4C163 /* RetryStateSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E38AA903063090600C4C163 /* RetryStateSpec.swift */; }; 9A47F74A2F3D4CBF0001FD9C /* ContextSummarizerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A47F7492F3D4CBF0001FD9C /* ContextSummarizerSpec.swift */; }; 9A47F74C2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */; }; 9A47F74D2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */; }; @@ -223,6 +224,19 @@ 9AE1D0A32F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */; }; 9AE1D0A42F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */; }; 9AE1D0A62F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */; }; + 9AE1D0B12F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; + 9AE1D0B22F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; + 9AE1D0B32F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; + 9AE1D0B42F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; + 9AE1D0C12F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0C22F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0C32F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0C42F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0D12F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; + 9AE1D0D22F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; + 9AE1D0D32F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; + 9AE1D0D42F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; + 9AF0E1012F5A1C000001FD9C /* LDValueSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF0E1002F5A1C000001FD9C /* LDValueSpec.swift */; }; A3047D642A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5D2A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift */; }; A3047D652A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5E2A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift */; }; A3047D662A606B6000F568E0 /* EnvironmentReporterChainBaseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5F2A606B6000F568E0 /* EnvironmentReporterChainBaseSpec.swift */; }; @@ -255,10 +269,6 @@ A350386E2F4F4F610032BA9F /* IdentifySeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */; }; A350386F2F4F4F610032BA9F /* IdentifySeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */; }; A35038702F4F4F610032BA9F /* IdentifySeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */; }; - FED0000000000000000000C1 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; - FED0000000000000000000C2 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; - FED0000000000000000000C3 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; - FED0000000000000000000C4 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; A35038772F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */; }; A35038782F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */; }; A35038792F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */; }; @@ -268,7 +278,6 @@ A350387E2F4F5A380032BA9F /* Data+Compression.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350387B2F4F5A380032BA9F /* Data+Compression.swift */; }; A350387F2F4F5A380032BA9F /* Data+Compression.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350387B2F4F5A380032BA9F /* Data+Compression.swift */; }; A35038812F4F965E0032BA9F /* LDClientIdentifyHookSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038802F4F965E0032BA9F /* LDClientIdentifyHookSpec.swift */; }; - FED0000000000000000000D1 /* LDClientTrackHookSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000D0 /* LDClientTrackHookSpec.swift */; }; A35038832F4F96680032BA9F /* LDClientEvaluationHookSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038822F4F96680032BA9F /* LDClientEvaluationHookSpec.swift */; }; A35038852F4F96CA0032BA9F /* WatchOSEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038842F4F96CA0032BA9F /* WatchOSEnvironmentReporterSpec.swift */; }; A3570F5A28527B8200CF241A /* LDContextCodableSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3570F5928527B8200CF241A /* LDContextCodableSpec.swift */; }; @@ -326,14 +335,6 @@ A3A8BCD42B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; A3A8BCD52B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; A3BA7CE92BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; - 9AE1D0C12F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; - 9AE1D0C22F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; - 9AE1D0C32F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; - 9AE1D0C42F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; - 9AE1D0B12F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; - 9AE1D0B22F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; - 9AE1D0B32F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; - 9AE1D0B42F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; A3BA7CEA2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; A3BA7CEB2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; A3BA7CEC2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; @@ -352,7 +353,6 @@ A3C6F7662B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; }; A3C6F7672B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; }; A3FFE1132B7D4BA2009EF93F /* LDValueDecoderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */; }; - 9AF0E1012F5A1C000001FD9C /* LDValueSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF0E1002F5A1C000001FD9C /* LDValueSpec.swift */; }; B40B419C249ADA6B00CD0726 /* DiagnosticCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */; }; B4265EB124E7390C001CFD2C /* TestUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4265EB024E7390C001CFD2C /* TestUtil.swift */; }; B468E71024B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B468E70F24B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift */; }; @@ -400,6 +400,11 @@ C443A41023186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; C443A41123186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; C443A41223186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; + FED0000000000000000000C1 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; + FED0000000000000000000C2 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; + FED0000000000000000000C3 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; + FED0000000000000000000C4 /* TrackSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000C0 /* TrackSeriesContext.swift */; }; + FED0000000000000000000D1 /* LDClientTrackHookSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED0000000000000000000D0 /* LDClientTrackHookSpec.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -468,7 +473,6 @@ /* Begin PBXFileReference section */ 29F9D19D2812E005008D12C0 /* ObjcLDValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjcLDValue.swift; sourceTree = ""; }; 29FE1297280413D4008CC918 /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = ""; }; - 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLock.swift; sourceTree = ""; }; 3D24060F2E0D90E000F91253 /* EnvironmentMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetadata.swift; sourceTree = ""; }; 3D2406102E0D90E000F91253 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = ""; }; 3D2406112E0D90E000F91253 /* PluginMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginMetadata.swift; sourceTree = ""; }; @@ -549,10 +553,16 @@ 83F0A5631FB5F33800550A95 /* LDConfigSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDConfigSpec.swift; sourceTree = ""; }; 83FEF8DC1F266742001CF12C /* FlagSynchronizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlagSynchronizer.swift; sourceTree = ""; }; 83FEF8DE1F2667E4001CF12C /* EventReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventReporter.swift; sourceTree = ""; }; + 8E38AA8B306308E500C4C163 /* RetryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryState.swift; sourceTree = ""; }; + 8E38AA903063090600C4C163 /* RetryStateSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStateSpec.swift; sourceTree = ""; }; 9A47F7492F3D4CBF0001FD9C /* ContextSummarizerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizerSpec.swift; sourceTree = ""; }; 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizer.swift; sourceTree = ""; }; 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduper.swift; sourceTree = ""; }; 9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduperSpec.swift; sourceTree = ""; }; + 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookDecorator.swift; sourceTree = ""; }; + 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DedupingHook.swift; sourceTree = ""; }; + 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLock.swift; sourceTree = ""; }; + 9AF0E1002F5A1C000001FD9C /* LDValueSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueSpec.swift; sourceTree = ""; }; A3047D5D2A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKEnvironmentReporterSpec.swift; sourceTree = ""; }; A3047D5E2A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IOSEnvironmentReporterSpec.swift; sourceTree = ""; }; A3047D5F2A606B6000F568E0 /* EnvironmentReporterChainBaseSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentReporterChainBaseSpec.swift; sourceTree = ""; }; @@ -566,11 +576,9 @@ A33A5F7928466D04000C29C7 /* LDContextStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDContextStub.swift; sourceTree = ""; }; A3470C362B7C1ACE00951CEE /* LDValueDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueDecoder.swift; sourceTree = ""; }; A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifySeriesContext.swift; sourceTree = ""; }; - FED0000000000000000000C0 /* TrackSeriesContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSeriesContext.swift; sourceTree = ""; }; A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientIdentifyHook.swift; sourceTree = ""; }; A350387B2F4F5A380032BA9F /* Data+Compression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Compression.swift"; sourceTree = ""; }; A35038802F4F965E0032BA9F /* LDClientIdentifyHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientIdentifyHookSpec.swift; sourceTree = ""; }; - FED0000000000000000000D0 /* LDClientTrackHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientTrackHookSpec.swift; sourceTree = ""; }; A35038822F4F96680032BA9F /* LDClientEvaluationHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientEvaluationHookSpec.swift; sourceTree = ""; }; A35038842F4F96CA0032BA9F /* WatchOSEnvironmentReporterSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchOSEnvironmentReporterSpec.swift; sourceTree = ""; }; A3570F5928527B8200CF241A /* LDContextCodableSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDContextCodableSpec.swift; sourceTree = ""; }; @@ -590,15 +598,12 @@ A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheddingQueue.swift; sourceTree = ""; }; A3BA7CE82BD056920000DB28 /* Hook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hook.swift; sourceTree = ""; }; - 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DedupingHook.swift; sourceTree = ""; }; - 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookDecorator.swift; sourceTree = ""; }; A3BA7CED2BD059180000DB28 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationSeriesContext.swift; sourceTree = ""; }; A3BA7D032BD2BD620000DB28 /* TestContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContext.swift; sourceTree = ""; }; A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheddingQueueSpec.swift; sourceTree = ""; }; A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyTypes.swift; sourceTree = ""; }; A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueDecoderSpec.swift; sourceTree = ""; }; - 9AF0E1002F5A1C000001FD9C /* LDValueSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueSpec.swift; sourceTree = ""; }; B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticCacheSpec.swift; sourceTree = ""; }; B4265EB024E7390C001CFD2C /* TestUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtil.swift; sourceTree = ""; }; B468E70F24B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjcLDEvaluationDetail.swift; sourceTree = ""; }; @@ -613,6 +618,8 @@ C43C37E0236BA050003C1624 /* LDEvaluationDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDEvaluationDetail.swift; sourceTree = ""; }; C443A4092315AA4D00145710 /* NetworkReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkReporter.swift; sourceTree = ""; }; C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionModeChangeObserver.swift; sourceTree = ""; }; + FED0000000000000000000C0 /* TrackSeriesContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSeriesContext.swift; sourceTree = ""; }; + FED0000000000000000000D0 /* LDClientTrackHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientTrackHookSpec.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -693,6 +700,7 @@ 831D8B751F72A48900ED65E8 /* ServiceObjects */ = { isa = PBXGroup; children = ( + 8E38AA903063090600C4C163 /* RetryStateSpec.swift */, 9A47F7492F3D4CBF0001FD9C /* ContextSummarizerSpec.swift */, 9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */, 50EE85C62EA0749C007CC662 /* TimeoutExecutorSpec.swift */, @@ -960,6 +968,7 @@ 83FEF8D91F2666BF001CF12C /* ServiceObjects */ = { isa = PBXGroup; children = ( + 8E38AA8B306308E500C4C163 /* RetryState.swift */, 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */, 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */, A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */, @@ -1482,6 +1491,7 @@ A3BA7CEC2BD056920000DB28 /* Hook.swift in Sources */, 9AE1D0C12F4A1B000001FD9C /* DedupingHook.swift in Sources */, 9AE1D0B12F4A1B000001FD9C /* HookDecorator.swift in Sources */, + 8E38AA8D306308E500C4C163 /* RetryState.swift in Sources */, 832D68A5224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4632A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6FA2A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, @@ -1581,6 +1591,7 @@ B4C9D43A2489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, A35038792F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */, 831EF36A20655E730001C643 /* ObjcLDChangedFlag.swift in Sources */, + 8E38AA8F306308E500C4C163 /* RetryState.swift in Sources */, 50EE85C22EA0487F007CC662 /* TimeoutExecutor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1655,6 +1666,7 @@ A3BA7CE92BD056920000DB28 /* Hook.swift in Sources */, 9AE1D0C32F4A1B000001FD9C /* DedupingHook.swift in Sources */, 9AE1D0B32F4A1B000001FD9C /* HookDecorator.swift in Sources */, + 8E38AA8E306308E500C4C163 /* RetryState.swift in Sources */, 832D68A2224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4602A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6F72A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, @@ -1708,6 +1720,7 @@ A3047D652A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift in Sources */, 83EBCBB120D9C7B5003A7142 /* FlagCounterSpec.swift in Sources */, A3047D642A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift in Sources */, + 8E38AA913063090600C4C163 /* RetryStateSpec.swift in Sources */, 83B8C2451FE360CF0082B8A9 /* FlagChangeNotifierSpec.swift in Sources */, 8335299E1FC37727001166F8 /* FlagMaintainingMock.swift in Sources */, A3047D692A606B6000F568E0 /* ApplicationInfoEnvironmentReporterSpec.swift in Sources */, @@ -1803,6 +1816,7 @@ A3BA7CEA2BD056920000DB28 /* Hook.swift in Sources */, 9AE1D0C42F4A1B000001FD9C /* DedupingHook.swift in Sources */, 9AE1D0B42F4A1B000001FD9C /* HookDecorator.swift in Sources */, + 8E38AA8C306308E500C4C163 /* RetryState.swift in Sources */, 83D9EC952062DEAB004D7FA6 /* Date.swift in Sources */, A35AD4612A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6F82A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift new file mode 100644 index 00000000..2fff3747 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The delay bounds for one retry regime. +struct RetryRegime { + /// The base delay for the first attempt in this regime. + let initialDelay: TimeInterval + /// The largest delay this regime produces. + let maxDelay: TimeInterval +} + +/// Computes the wait before the next connection attempt using exponential +/// backoff with jitter. A `normal` failure advances the backoff on the current +/// regime. An `unexpected` failure moves to the extended regime and stays there +/// until `reset()`. It is not thread safe. +final class RetryState { + private let normal: RetryRegime + private let extended: RetryRegime + private let minDelay: TimeInterval + + private var inExtendedRegime = false + private var attempts = 0 + + init(normal: RetryRegime, extended: RetryRegime, minDelay: TimeInterval) { + self.normal = normal + self.extended = extended + self.minDelay = minDelay + } + + /// Streaming. Normal backoff runs from `initialReconnectDelay` up to 30 + /// seconds. Extended runs from 5 minutes up to 1 hour. There is no wait floor. + static func forStreaming(initialReconnectDelay: TimeInterval) -> RetryState { + RetryState( + normal: RetryRegime(initialDelay: initialReconnectDelay, maxDelay: 30), + extended: RetryRegime(initialDelay: 5 * 60, maxDelay: 60 * 60), + minDelay: 0) + } + + /// Polling. Normal retries at `pollInterval`. An unexpected failure backs off + /// from 5 minutes up to 1 hour. + static func forPolling(pollInterval: TimeInterval) -> RetryState { + RetryState( + normal: RetryRegime(initialDelay: pollInterval, maxDelay: pollInterval), + extended: RetryRegime(initialDelay: max(5 * 60, pollInterval), + maxDelay: max(60 * 60, pollInterval)), + minDelay: pollInterval) + } + + func recordFailure(unexpected: Bool) { + if unexpected && !inExtendedRegime { + inExtendedRegime = true + attempts = 1 + return + } + attempts += 1 + } + + func reset() { + inExtendedRegime = false + attempts = 0 + } + + func nextDelay() -> TimeInterval { + let regime = inExtendedRegime ? extended : normal + let exponent = Double(max(0, attempts - 1)) + let delay = min(regime.maxDelay, regime.initialDelay * pow(2, exponent)) + return max(minDelay, delay - Double.random(in: 0...(delay / 2))) + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift new file mode 100644 index 00000000..8d0718b2 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift @@ -0,0 +1,77 @@ +import Foundation +import XCTest + +@testable import LaunchDarkly + +final class RetryStateSpec: XCTestCase { + // nextDelay applies random jitter. Each result is asserted to fall within a + // range over many samples rather than against a fixed value. + private func assertDelay(_ retry: RetryState, + inClosedRange range: ClosedRange, + file: StaticString = #filePath, + line: UInt = #line) { + for _ in 0..<50 { + let delay = retry.nextDelay() + XCTAssertTrue(range.contains(delay), "\(delay) is outside \(range)", file: file, line: line) + } + } + + func testStreamingRetryLifecycle() { + let retry = RetryState.forStreaming(initialReconnectDelay: 1) + + // Normal failures back off from the initial delay and double to the 30s ceiling. + for range in [0.5...1, 1...2, 2...4, 4...8, 8...16, 15...30, 15...30] as [ClosedRange] { + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: range) + } + + // An unexpected failure switches to the extended regime, restarting from 5 minutes. + retry.recordFailure(unexpected: true) + assertDelay(retry, inClosedRange: 150...300) + + // Further normal failures remain in the extended regime and double to the 1h ceiling. + for range in [300...600, 600...1200, 1200...2400, 1800...3600, 1800...3600] as [ClosedRange] { + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: range) + } + + // A reset returns to the normal regime, backing off from the initial delay again. + retry.reset() + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: 0.5...1) + } + + func testPollingRetryLifecycle() { + let retry = RetryState.forPolling(pollInterval: 30) + + // Normal failures hold the poll interval with no escalation. + for _ in 0..<3 { + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: 30...30) + } + + // An unexpected failure switches to the extended regime, restarting from 5 minutes. + retry.recordFailure(unexpected: true) + assertDelay(retry, inClosedRange: 150...300) + + // Further normal failures remain in the extended regime and double to the 1h ceiling. + for range in [300...600, 600...1200, 1200...2400, 1800...3600, 1800...3600] as [ClosedRange] { + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: range) + } + + // A reset returns to the poll interval. + retry.reset() + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: 30...30) + } + + func testPollingWaitFloorRaisesToPollInterval() { + // A poll interval above the 5 minute extended base floors every delay at the interval. + let retry = RetryState.forPolling(pollInterval: 600) + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: 600...600) + retry.recordFailure(unexpected: true) + assertDelay(retry, inClosedRange: 600...600) + } +} From a879ebf709c2f348710e11be46d5040755859e1d Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 22 Sep 2026 22:30:26 -0700 Subject: [PATCH 02/12] feat: Retry the streaming data source with backoff instead of stopping on failure --- LaunchDarkly/LaunchDarkly/LDClient.swift | 11 +--- .../Models/ConnectionInformation.swift | 4 +- .../ServiceObjects/FlagSynchronizer.swift | 56 ++++++++++++++++--- .../ServiceObjects/RetryState.swift | 8 +-- .../LaunchDarklyTests/LDClientSpec.swift | 29 ++++------ .../ServiceObjects/FlagSynchronizerSpec.swift | 2 +- .../ServiceObjects/RetryStateSpec.swift | 2 +- 7 files changed, 66 insertions(+), 46 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index ad99577b..4dde3064 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -697,16 +697,7 @@ public class LDClient { // restarts will honor the appropriate polling delay. self.updateCacheFreshness(context: self.context) case .error(let synchronizingError): - process(synchronizingError: synchronizingError, logPrefix: typeName(and: #function)) - } - } - - private func process(synchronizingError: SynchronizingError, logPrefix: String) { - connectionInformation = ConnectionInformation.synchronizingErrorCheck(synchronizingError: synchronizingError, connectionInformation: connectionInformation) - if synchronizingError.isTerminal { - os_log("%s data source terminal error; stopping flag delivery", log: config.logger, type: .debug, logPrefix) - flagSynchronizer.isOnline = false - initialized = true + connectionInformation = ConnectionInformation.recordSynchronizingError(synchronizingError, streamingMode: flagSynchronizer.streamingMode, connectionInformation: connectionInformation) } } diff --git a/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift b/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift index 17a864e0..4ca487cf 100644 --- a/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift +++ b/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift @@ -92,7 +92,7 @@ public struct ConnectionInformation: Codable, CustomStringConvertible { } // Used for parsing SynchronizingError in LDClient.process - static func synchronizingErrorCheck(synchronizingError: SynchronizingError, connectionInformation: ConnectionInformation) -> ConnectionInformation { + static func recordSynchronizingError(_ synchronizingError: SynchronizingError, streamingMode: LDStreamingMode, connectionInformation: ConnectionInformation) -> ConnectionInformation { var connectionInformationVar = connectionInformation if synchronizingError.isClientUnauthorized { connectionInformationVar.lastConnectionFailureReason = .unauthorized @@ -112,7 +112,7 @@ public struct ConnectionInformation: Codable, CustomStringConvertible { } connectionInformationVar.lastFailedConnection = Date() if synchronizingError.isTerminal { - connectionInformationVar.currentConnectionMode = .offline + connectionInformationVar.currentConnectionMode = (streamingMode == .streaming) ? .establishingStreamingConnection : .polling } return connectionInformationVar } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index d00e1f35..45c13f24 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -72,6 +72,10 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { let streamingMode: LDStreamingMode + // Not thread-safe. Only accessed on the event source callback queue (eventSourceErrorHandler). + private let retryState: RetryState + private static let healthyResetThreshold: TimeInterval = 60 + var isOnline: Bool { get { isOnlineQueue.sync { _isOnline } } set { @@ -91,6 +95,10 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { private var syncQueue = DispatchQueue(label: Constants.queueName, qos: .utility) private var eventSourceStarted: Date? + // Only accessed on the event source callback queue. + private var connectedAt: Date? + // Only accessed on isOnlineQueue. + private var reconnectWorkItem: DispatchWorkItem? init(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, @@ -100,6 +108,9 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { onSyncComplete: FlagSyncCompleteClosure?) { self.streamingMode = streamingMode self.pollingInterval = pollingInterval + self.retryState = (streamingMode == .streaming) + ? RetryState.forStreaming() + : RetryState.forPolling(pollInterval: pollingInterval) self.useReport = useReport self.lastCachedRequestedTime = lastUpdated self.service = service @@ -142,6 +153,8 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { } private func stopEventSource() { + reconnectWorkItem?.cancel() + reconnectWorkItem = nil guard eventSource != nil else { os_log("%s aborted. Clientstream is not connected.", log: service.config.logger, type: .debug, typeName(and: #function)) @@ -153,6 +166,19 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { eventSource = nil } + private func scheduleReconnect(after delay: TimeInterval) { + reconnectWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in self?.reconnect() } + reconnectWorkItem = workItem + isOnlineQueue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func reconnect() { + guard _isOnline + else { return } + startEventSource() + } + // MARK: Polling private func startPolling() { @@ -290,16 +316,23 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { } eventSourceStarted = now - guard let unsuccessfulResponseError = error as? UnsuccessfulResponseError - else { return .proceed } - // Now we know that we received an error HTTP response code - let responseCode: Int = unsuccessfulResponseError.responseCode - if HTTPURLResponse.StatusCodes.isTerminalStatusCode(responseCode) { - reportSyncComplete(.error(.streamError(error))) - return .shutdown + // If the stream stayed healthy long enough before failing, reset the backoff. + if let connectedAt = connectedAt, now.timeIntervalSince(connectedAt) >= FlagSynchronizer.healthyResetThreshold { + retryState.reset() } - // Otherwise we will retry - return .proceed + connectedAt = nil + + retryState.recordFailure(unexpected: SynchronizingError.streamError(error).isTerminal) + let delay = retryState.nextDelay() + os_log("%s stream error; reconnecting in %.3fs. error: %s", log: service.config.logger, type: .debug, typeName(and: #function), delay, String(describing: error)) + reportSyncComplete(.error(.streamError(error))) + + // Tear down the failed connection now, then reconnect after the backoff. + isOnlineQueue.async { [weak self] in + self?.stopEventSource() + self?.scheduleReconnect(after: delay) + } + return .shutdown } func shouldAbortStreamUpdate() -> Bool { @@ -342,6 +375,11 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { guard !shouldAbortStreamUpdate() else { return } + // The first payload on a fresh stream marks healthy operation. + if connectedAt == nil { + connectedAt = Date() + } + switch eventType { case "ping": makeFlagRequest(isOnline: isOnline) case "put": diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift index 2fff3747..11671268 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift @@ -26,11 +26,11 @@ final class RetryState { self.minDelay = minDelay } - /// Streaming. Normal backoff runs from `initialReconnectDelay` up to 30 - /// seconds. Extended runs from 5 minutes up to 1 hour. There is no wait floor. - static func forStreaming(initialReconnectDelay: TimeInterval) -> RetryState { + /// Streaming. Normal backoff runs from 1 second up to 30 seconds. Extended + /// runs from 5 minutes up to 1 hour. There is no wait floor. + static func forStreaming() -> RetryState { RetryState( - normal: RetryRegime(initialDelay: initialReconnectDelay, maxDelay: 30), + normal: RetryRegime(initialDelay: 1, maxDelay: 30), extended: RetryRegime(initialDelay: 5 * 60, maxDelay: 60 * 60), minDelay: 0) } diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index 39d475a3..d82c2bac 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -1275,14 +1275,14 @@ final class LDClientSpec: QuickSpec { statusCode: 403, httpVersion: DarklyServiceMock.Constants.httpVersion, headerFields: nil) - it("stops only the data source and marks initialized on a terminal flag error") { + it("keeps the data source running and reconnecting on a terminal flag error") { let testContext = TestContext(startOnline: true) testContext.start() testContext.onSyncComplete?(.error(.response(forbiddenError))) - expect(testContext.flagSynchronizerMock.isOnline).to(beFalse()) - expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .offline - expect(testContext.subject.isInitialized).to(beTrue()) + expect(testContext.flagSynchronizerMock.isOnline).to(beTrue()) + expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .establishingStreamingConnection + expect(testContext.subject.isInitialized).to(beFalse()) expect(testContext.subject.isOnline).to(beTrue()) expect(testContext.eventReporterMock.isOnline).to(beTrue()) } @@ -1296,15 +1296,6 @@ final class LDClientSpec: QuickSpec { expect(testContext.subject.getConnectionInformation().currentConnectionMode) == modeBefore expect(testContext.subject.isOnline).to(beTrue()) } - it("restarts the data source when set online again after a terminal error") { - let testContext = TestContext(startOnline: true) - testContext.start() - testContext.onSyncComplete?(.error(.response(forbiddenError))) - expect(testContext.flagSynchronizerMock.isOnline).to(beFalse()) - - testContext.subject.setOnline(true) - expect(testContext.flagSynchronizerMock.isOnline).toEventually(beTrue()) - } it("event delivery errors do not affect flag delivery or connection information") { let testContext = TestContext(startOnline: true) testContext.start() @@ -1323,7 +1314,7 @@ final class LDClientSpec: QuickSpec { testContext.start() testContext.subject.flagChangeNotifier = ClientServiceMockFactory(config: testContext.config).makeFlagChangeNotifier() testContext.onSyncComplete?(.error(.response(forbiddenError))) - expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .offline + expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .establishingStreamingConnection waitUntil { done in testContext.changeNotifierMock.notifyObserversCallback = done @@ -1331,12 +1322,12 @@ final class LDClientSpec: QuickSpec { } expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .streaming } - it("recovers to polling when a poll succeeds after a terminal error") { + it("stays in polling mode through a terminal error") { let testContext = TestContext(startOnline: true) testContext.start() testContext.flagSynchronizerMock.streamingMode = .polling testContext.onSyncComplete?(.error(.response(forbiddenError))) - expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .offline + expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .polling testContext.onSyncComplete?(.upToDate) expect(testContext.subject.getConnectionInformation().currentConnectionMode) == .polling @@ -1786,10 +1777,10 @@ final class LDClientSpec: QuickSpec { NotificationCenter.default.post(name: SystemCapabilities.foregroundNotification!, object: self) expect(testContext.subject.isInitialized) == false } - it("becomes true when the client is unauthorized") { + it("stays uninitialized when the client is unauthorized") { let testContext = TestContext(startOnline: true) testContext.start() - expect(testContext.subject.isInitialized) == false + expect(testContext.subject.isInitialized).to(beFalse()) let unauthorized = HTTPURLResponse(url: URL(string: "https://app.launchdarkly.com")!, statusCode: 401, @@ -1797,7 +1788,7 @@ final class LDClientSpec: QuickSpec { headerFields: nil) testContext.onSyncComplete?(.error(.response(unauthorized))) - expect(testContext.subject.isInitialized).toEventually(beTrue(), timeout: DispatchTimeInterval.seconds(2)) + expect(testContext.subject.isInitialized).to(beFalse()) } it("becomes true when the mobile key is empty") { let config = LDConfig.stub(mobileKey: "", autoEnvAttributes: .disabled, isDebugBuild: false) diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift index 572bacc2..27e9aa62 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift @@ -614,7 +614,7 @@ final class FlagSynchronizerSpec: QuickSpec { expect(testContext.serviceMock.getFeatureFlagsCallCount) == 0 expect(testContext.serviceMock.createEventSourceCallCount) == 1 expect(testContext.serviceMock.createdEventSource?.startCallCount) == 1 - expect(testContext.serviceMock.createdEventSource?.stopCallCount) == 0 + expect(testContext.serviceMock.createdEventSource?.stopCallCount).toEventually(equal(1)) expect(syncError).toNot(beNil()) expect(syncError?.isClientUnauthorized).to(beFalse()) diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift index 8d0718b2..94385b89 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift @@ -17,7 +17,7 @@ final class RetryStateSpec: XCTestCase { } func testStreamingRetryLifecycle() { - let retry = RetryState.forStreaming(initialReconnectDelay: 1) + let retry = RetryState.forStreaming() // Normal failures back off from the initial delay and double to the 30s ceiling. for range in [0.5...1, 1...2, 2...4, 4...8, 8...16, 15...30, 15...30] as [ClosedRange] { From 8a46f126bdc63f4c4c2d600f4e862fbd75e099b3 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 23 Sep 2026 10:47:35 -0700 Subject: [PATCH 03/12] chore: Remove offline connection-mode recovery made moot by data source retry --- LaunchDarkly/LaunchDarkly/LDClient.swift | 8 ++++---- .../LaunchDarkly/Models/ConnectionInformation.swift | 6 +----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 4dde3064..98e7ff58 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -676,21 +676,21 @@ public class LDClient { case let .flagCollection((flagCollection, etag)): os_log("%s: got flag collection with %d flags.", log: config.logger, type: .debug, typeName(and: #function), flagCollection.flags.count) let oldStoredItems = flagStore.storedItems - connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation, streamingMode: flagSynchronizer.streamingMode) + connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation) flagStore.replaceStore(newStoredItems: StoredItems(items: flagCollection.flags)) self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems, etag: etag) case let .patch(featureFlag): let oldStoredItems = flagStore.storedItems - connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation, streamingMode: flagSynchronizer.streamingMode) + connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation) flagStore.updateStore(updatedFlag: featureFlag) self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems, etag: nil) case let .delete(deleteResponse): let oldStoredItems = flagStore.storedItems - connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation, streamingMode: flagSynchronizer.streamingMode) + connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation) flagStore.deleteFlag(deleteResponse: deleteResponse) self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems, etag: nil) case .upToDate: - connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation, streamingMode: flagSynchronizer.streamingMode) + connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation) flagChangeNotifier.notifyUnchanged() // If a polling request receives a 304 not modified, we still need // to update the "last updated" field of the cache so subsequent diff --git a/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift b/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift index 4ca487cf..aeb39f90 100644 --- a/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift +++ b/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift @@ -118,12 +118,8 @@ public struct ConnectionInformation: Codable, CustomStringConvertible { } // Reconciles the connection mode and flag validity after a successful sync. - static func checkEstablishingStreaming(connectionInformation: ConnectionInformation, streamingMode: LDStreamingMode) -> ConnectionInformation { + static func checkEstablishingStreaming(connectionInformation: ConnectionInformation) -> ConnectionInformation { var connectionInformationVar = connectionInformation - // Recover a terminal-error offline into the connecting mode. The checks below finish the transition. - if connectionInformationVar.currentConnectionMode == .offline { - connectionInformationVar.currentConnectionMode = (streamingMode == .streaming) ? .establishingStreamingConnection : .polling - } if connectionInformationVar.currentConnectionMode == .establishingStreamingConnection { connectionInformationVar.currentConnectionMode = .streaming connectionInformationVar.lastKnownFlagValidity = nil From 544b13213725b4011975c3e667a71f4d0e2bb5db Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 23 Sep 2026 13:06:56 -0700 Subject: [PATCH 04/12] chore: Schedule the streaming reconnect with a one-shot LDTimer --- .../ServiceObjects/FlagSynchronizer.swift | 14 +++++++------- .../LaunchDarkly/ServiceObjects/LDTimer.swift | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index 45c13f24..9a1214c0 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -98,7 +98,7 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { // Only accessed on the event source callback queue. private var connectedAt: Date? // Only accessed on isOnlineQueue. - private var reconnectWorkItem: DispatchWorkItem? + private var reconnectTimer: TimeResponding? init(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, @@ -153,8 +153,8 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { } private func stopEventSource() { - reconnectWorkItem?.cancel() - reconnectWorkItem = nil + reconnectTimer?.cancel() + reconnectTimer = nil guard eventSource != nil else { os_log("%s aborted. Clientstream is not connected.", log: service.config.logger, type: .debug, typeName(and: #function)) @@ -167,10 +167,10 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { } private func scheduleReconnect(after delay: TimeInterval) { - reconnectWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in self?.reconnect() } - reconnectWorkItem = workItem - isOnlineQueue.asyncAfter(deadline: .now() + delay, execute: workItem) + reconnectTimer?.cancel() + reconnectTimer = LDTimer(withTimeInterval: delay, fireQueue: isOnlineQueue, repeats: false) { [weak self] in + self?.reconnect() + } } private func reconnect() { diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift index 11e180be..a45fee5b 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift @@ -3,7 +3,7 @@ import Foundation protocol TimeResponding { var fireDate: Date? { get } - init(withTimeInterval: TimeInterval, fireQueue: DispatchQueue, fireAt: Date?, execute: @escaping () -> Void) + init(withTimeInterval: TimeInterval, fireQueue: DispatchQueue, fireAt: Date?, repeats: Bool, execute: @escaping () -> Void) func cancel() } @@ -15,16 +15,16 @@ final class LDTimer: TimeResponding { private(set) var isCancelled: Bool = false var fireDate: Date? { timer?.fireDate } - init(withTimeInterval timeInterval: TimeInterval, fireQueue: DispatchQueue = DispatchQueue.main, fireAt: Date? = nil, execute: @escaping () -> Void) { + init(withTimeInterval timeInterval: TimeInterval, fireQueue: DispatchQueue = DispatchQueue.main, fireAt: Date? = nil, repeats: Bool = true, execute: @escaping () -> Void) { self.fireQueue = fireQueue self.execute = execute // the run loop retains the timer, so the property is weak to avoid a retain cycle. Setting the timer to a strong reference is important so that the timer doesn't get nil'd before it's added to the run loop. let timer: Timer if let at = fireAt { - timer = Timer(fireAt: at, interval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true) + timer = Timer(fireAt: at, interval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: repeats) } else { - timer = Timer(timeInterval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true) + timer = Timer(timeInterval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: repeats) } self.timer = timer RunLoop.main.add(timer, forMode: RunLoop.Mode.default) From dd1b646ee099c083fcdfce8f0697ad2865779694 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 23 Sep 2026 16:24:17 -0700 Subject: [PATCH 05/12] feat: Back off the polling data source on unexpected failures --- .../ServiceObjects/FlagSynchronizer.swift | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index 9a1214c0..9c3c70db 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -67,12 +67,14 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { let service: DarklyServiceProvider private var eventSource: DarklyStreamingProvider? + // Only accessed on isOnlineQueue. private var flagRequestTimer: TimeResponding? var onSyncComplete: FlagSyncCompleteClosure? let streamingMode: LDStreamingMode - // Not thread-safe. Only accessed on the event source callback queue (eventSourceErrorHandler). + // Not thread-safe, but only accessed on one queue per instance: the event source callback queue for + // streaming, or isOnlineQueue for polling. private let retryState: RetryState private static let healthyResetThreshold: TimeInterval = 60 @@ -99,6 +101,8 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { private var connectedAt: Date? // Only accessed on isOnlineQueue. private var reconnectTimer: TimeResponding? + // Consecutive successful polls, toward the reset threshold. Only accessed on isOnlineQueue. + private var consecutivePollSuccesses = 0 init(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, @@ -188,20 +192,18 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { return } - // We should fire right away, unless we know how fresh the cache is and can - // adjust accordingly. - var fireAt = Date.distantPast + var initialDelay: TimeInterval = 0 if let lastTime = self.lastCachedRequestedTime { - fireAt = lastTime.addingTimeInterval(pollingInterval) - // If we do consider the cached values already fresh enough, we should - // signal completion immediately + // The cache is still fresh, so delay the first poll until the cached flags would go stale. + initialDelay = max(0, lastTime.addingTimeInterval(pollingInterval).timeIntervalSinceNow) + // We loaded cached flags, so report completion immediately rather than blocking on the first poll. syncQueue.async { [self] in guard isOnline else { return } reportSyncComplete(.upToDate) } } - flagRequestTimer = LDTimer(withTimeInterval: pollingInterval, fireQueue: syncQueue, fireAt: fireAt, execute: processTimer) + scheduleNextPoll(after: initialDelay) os_log("%s", log: service.config.logger, type: .debug, typeName(and: #function)) } @@ -217,6 +219,29 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { flagRequestTimer = nil } + private func scheduleNextPoll(after delay: TimeInterval) { + flagRequestTimer?.cancel() + flagRequestTimer = LDTimer(withTimeInterval: delay, fireQueue: syncQueue, repeats: false, execute: processTimer) + } + + private func pollDidComplete(failed: Bool, unexpected: Bool) { + isOnlineQueue.async { [weak self] in + guard let self = self, self._isOnline, self.streamingMode == .polling + else { return } + if failed { + self.retryState.recordFailure(unexpected: unexpected) + self.consecutivePollSuccesses = 0 + self.scheduleNextPoll(after: self.retryState.nextDelay()) + } else { + self.consecutivePollSuccesses += 1 + if self.consecutivePollSuccesses == 2 { + self.retryState.reset() + } + self.scheduleNextPoll(after: self.pollingInterval) + } + } + } + @objc private func processTimer() { makeFlagRequest(isOnline: isOnline) } @@ -264,9 +289,14 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { service.resetFlagResponseCache(etag: nil) return } + var failed = false + var unexpected = false + defer { pollDidComplete(failed: failed, unexpected: unexpected) } + if let serviceResponseError = serviceResponse.error { os_log("%s error: %s", log: service.config.logger, type: .debug, typeName(and: #function), String(describing: serviceResponseError)) reportSyncComplete(.error(.request(serviceResponseError))) + failed = true return } if serviceResponse.urlResponse?.httpStatusCode == HTTPURLResponse.StatusCodes.notModified { @@ -276,13 +306,17 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { guard serviceResponse.urlResponse?.httpStatusCode == HTTPURLResponse.StatusCodes.ok else { os_log("%s response: %s", log: service.config.logger, type: .debug, typeName(and: #function), String(describing: serviceResponse.urlResponse)) - reportSyncComplete(.error(.response(serviceResponse.urlResponse))) + let syncError = SynchronizingError.response(serviceResponse.urlResponse) + reportSyncComplete(.error(syncError)) + failed = true + unexpected = syncError.isTerminal return } guard let data = serviceResponse.data, let flagCollection = try? JSONDecoder().decode(FeatureFlagCollection.self, from: data) else { reportDataError(serviceResponse.data) + failed = true return } reportSyncComplete(.flagCollection((flagCollection, serviceResponse.etag))) From d30eb2116b9f39db37f38fc0233e04fecc18baf8 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 23 Sep 2026 18:44:33 -0700 Subject: [PATCH 06/12] test: Cover data source retry and reconnect with deterministic seams --- .../ServiceObjects/FlagSynchronizer.swift | 30 +++++++ .../ServiceObjects/FlagSynchronizerSpec.swift | 78 +++++++++++++++++++ .../ServiceObjects/LDTimerSpec.swift | 20 ++++- 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index 9c3c70db..bc92f9c1 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -483,6 +483,36 @@ extension FlagSynchronizer { func testProcessFlagResponse(serviceResponse: ServiceResponse) { processFlagResponse(serviceResponse: serviceResponse) } + + // connectedAt is set on the event source callback queue; a test uses this on the + // thread where it drives eventSourceErrorHandler. + var testConnectedAt: Date? { + get { connectedAt } + set { connectedAt = newValue } + } + + // Marks the synchronizer online without starting a data source, so a test can + // drive pollDidComplete without real polls racing the assertions. + func testForceOnline() { + isOnlineQueue.sync { _isOnline = true } + } + + func testPollDidComplete(failed: Bool, unexpected: Bool) { + pollDidComplete(failed: failed, unexpected: unexpected) + } + + // Reads the poll delay on isOnlineQueue so it serializes after a pending pollDidComplete. + var testNextPollDelay: TimeInterval { + isOnlineQueue.sync { retryState.nextDelay() } + } + + func testReconnect() { + reconnect() + } + + var testReconnectFireDate: Date? { + isOnlineQueue.sync { reconnectTimer?.fireDate } + } } #endif diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift index 27e9aa62..a950f785 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift @@ -39,6 +39,7 @@ final class FlagSynchronizerSpec: QuickSpec { changeIsOnlineSpec() streamingEventSpec() pollingTimerFiresSpec() + pollingBackoffSpec() flagRequestSpec() } @@ -250,6 +251,7 @@ final class FlagSynchronizerSpec: QuickSpec { streamingDeleteEventSpec() streamingOtherEventSpec() streamingProcessingSpec() + streamingReconnectSpec() } } @@ -880,6 +882,82 @@ final class FlagSynchronizerSpec: QuickSpec { } } + func streamingReconnectSpec() { + describe("streaming reconnect") { + var testContext: TestContext! + + beforeEach { + testContext = TestContext(streamingMode: .streaming, useReport: false) + testContext.flagSynchronizer.isOnline = true + // The failed connection is torn down before the reconnect is armed. + testContext.flagSynchronizer.testEventSource = nil + } + + afterEach { + testContext.flagSynchronizer.isOnline = false + } + + it("connects a fresh event source when reconnecting") { + testContext.flagSynchronizer.testReconnect() + + expect(testContext.serviceMock.createEventSourceCallCount) == 2 + expect(testContext.serviceMock.createdEventSource?.startCallCount) == 1 + } + it("does not reconnect while offline") { + testContext.flagSynchronizer.isOnline = false + testContext.flagSynchronizer.testReconnect() + + expect(testContext.serviceMock.createEventSourceCallCount) == 1 + } + it("resets the backoff after a stream stays healthy") { + // A 405 is unexpected, so the stream moves into the multi-minute extended regime. + _ = testContext.providedErrorHandler!(UnsuccessfulResponseError(responseCode: HTTPURLResponse.StatusCodes.methodNotAllowed)) + // The next stream stayed healthy for over a minute before a recoverable failure. + testContext.flagSynchronizer.testConnectedAt = Date().addingTimeInterval(-61) + _ = testContext.providedErrorHandler!(UnsuccessfulResponseError(responseCode: HTTPURLResponse.StatusCodes.internalServerError)) + + guard let fireDate = testContext.flagSynchronizer.testReconnectFireDate + else { return fail("Expected a scheduled reconnect") } + // Back in the normal regime, so the next attempt is seconds away, not minutes. + expect(fireDate.timeIntervalSinceNow).to(beLessThan(60)) + } + it("keeps the extended backoff after a short-lived stream") { + _ = testContext.providedErrorHandler!(UnsuccessfulResponseError(responseCode: HTTPURLResponse.StatusCodes.methodNotAllowed)) + // The stream was healthy for only a moment, so the extended backoff holds. + testContext.flagSynchronizer.testConnectedAt = Date().addingTimeInterval(-10) + _ = testContext.providedErrorHandler!(UnsuccessfulResponseError(responseCode: HTTPURLResponse.StatusCodes.internalServerError)) + + guard let fireDate = testContext.flagSynchronizer.testReconnectFireDate + else { return fail("Expected a scheduled reconnect") } + expect(fireDate.timeIntervalSinceNow).to(beGreaterThanOrEqualTo(150)) + } + } + } + + func pollingBackoffSpec() { + describe("polling backoff") { + var testContext: TestContext! + + afterEach { + testContext.flagSynchronizer.isOnline = false + } + + it("resets the backoff after two consecutive successful polls") { + testContext = TestContext(streamingMode: .polling, useReport: false) + testContext.flagSynchronizer.testForceOnline() + + // An unexpected failure moves polling into the multi-minute extended regime. + testContext.flagSynchronizer.testPollDidComplete(failed: true, unexpected: true) + expect(testContext.flagSynchronizer.testNextPollDelay).to(beGreaterThanOrEqualTo(150)) + + // Two clean polls reset it back to the poll interval. + testContext.flagSynchronizer.testPollDidComplete(failed: false, unexpected: false) + testContext.flagSynchronizer.testPollDidComplete(failed: false, unexpected: false) + expect(testContext.flagSynchronizer.testNextPollDelay) == Constants.pollingInterval + } + } + } + func pollingTimerFiresSpec() { describe("polling timer fires") { var testContext: TestContext! diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift index 88baefc9..3b67b74c 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift @@ -11,10 +11,10 @@ final class LDTimerSpec: QuickSpec { let timeInterval: TimeInterval let fireDate: Date - init(timeInterval: TimeInterval = 60.0, execute: @escaping () -> Void) { + init(timeInterval: TimeInterval = 60.0, repeats: Bool = true, execute: @escaping () -> Void) { self.timeInterval = timeInterval self.fireDate = Date().addingTimeInterval(timeInterval) - ldTimer = LDTimer(withTimeInterval: timeInterval, fireQueue: fireQueue, execute: execute) + ldTimer = LDTimer(withTimeInterval: timeInterval, fireQueue: fireQueue, repeats: repeats, execute: execute) } } @@ -61,6 +61,22 @@ final class LDTimerSpec: QuickSpec { expect(testContext.ldTimer.isCancelled) == false expect(fireCount) == 2 + testContext.ldTimer.cancel() + } + it("fires a non repeating timer only once") { + var fireCount = 0 + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(timeInterval: 0.01, repeats: false, execute: { + fireCount += 1 + done() + }) + } + + // A non repeating timer invalidates itself after it fires, so it never fires again. + expect(testContext.ldTimer.timer?.isValid ?? false).to(beFalse()) + expect(fireCount) == 1 + testContext.ldTimer.cancel() } } From f11626473374a5de9af1f2d0d64843d4ecbec1cc Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 23 Sep 2026 19:19:20 -0700 Subject: [PATCH 07/12] feat: Declare FDv1 retry-conformance contract test capabilities --- ContractTests/Source/Controllers/SdkController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ContractTests/Source/Controllers/SdkController.swift b/ContractTests/Source/Controllers/SdkController.swift index 8bee1b2b..11f0808f 100644 --- a/ContractTests/Source/Controllers/SdkController.swift +++ b/ContractTests/Source/Controllers/SdkController.swift @@ -36,7 +36,9 @@ final class SdkController: RouteCollection { "client-prereq-events", "client-prereq-cycle-detection", "polling-gzip", - "client-per-context-summaries" + "client-per-context-summaries", + "retry-conformance-fdv1-streaming", + "retry-conformance-fdv1-polling" ] return StatusResponse( From 4d6260052dbc5965dbf4bb1fe4cba99112388ba4 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 24 Sep 2026 11:13:54 -0700 Subject: [PATCH 08/12] fix: Clear the streaming healthy marker when the stream closes --- .../LaunchDarkly/ServiceObjects/FlagSynchronizer.swift | 1 + .../ServiceObjects/FlagSynchronizerSpec.swift | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index bc92f9c1..d0b0a567 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -402,6 +402,7 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { public func onClosed() { os_log("%s EventSource closed", log: service.config.logger, type: .debug, typeName(and: #function)) + connectedAt = nil NotificationCenter.default.post(name: Notification.Name(FlagSynchronizer.Constants.didCloseEventSourceName), object: nil) } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift index a950f785..3534f20f 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift @@ -931,6 +931,13 @@ final class FlagSynchronizerSpec: QuickSpec { else { return fail("Expected a scheduled reconnect") } expect(fireDate.timeIntervalSinceNow).to(beGreaterThanOrEqualTo(150)) } + it("clears the healthy marker when the stream closes") { + // A closed stream must not leave its connect time behind for the next stream. + testContext.flagSynchronizer.testConnectedAt = Date().addingTimeInterval(-120) + testContext.providedEventHandler!.onClosed() + + expect(testContext.flagSynchronizer.testConnectedAt).to(beNil()) + } } } From 3808b605dc52e6c25f019e20d004042978666649 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 28 Sep 2026 15:10:58 -0700 Subject: [PATCH 09/12] refactor: Move success tracking and backoff constants into RetryState --- .../ServiceObjects/FlagSynchronizer.swift | 15 ++--- .../ServiceObjects/RetryState.swift | 61 +++++++++++++------ .../ServiceObjects/RetryStateSpec.swift | 25 ++++++-- 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index d0b0a567..e1e13bbc 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -101,8 +101,6 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { private var connectedAt: Date? // Only accessed on isOnlineQueue. private var reconnectTimer: TimeResponding? - // Consecutive successful polls, toward the reset threshold. Only accessed on isOnlineQueue. - private var consecutivePollSuccesses = 0 init(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, @@ -230,15 +228,10 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { else { return } if failed { self.retryState.recordFailure(unexpected: unexpected) - self.consecutivePollSuccesses = 0 - self.scheduleNextPoll(after: self.retryState.nextDelay()) } else { - self.consecutivePollSuccesses += 1 - if self.consecutivePollSuccesses == 2 { - self.retryState.reset() - } - self.scheduleNextPoll(after: self.pollingInterval) + self.retryState.recordSuccess() } + self.scheduleNextPoll(after: self.retryState.nextDelay()) } } @@ -350,9 +343,9 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { } eventSourceStarted = now - // If the stream stayed healthy long enough before failing, reset the backoff. + // A stream that stayed up long enough before failing is a success. if let connectedAt = connectedAt, now.timeIntervalSince(connectedAt) >= FlagSynchronizer.healthyResetThreshold { - retryState.reset() + retryState.recordSuccess() } connectedAt = nil diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift index 11671268..85c747c1 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift @@ -8,44 +8,58 @@ struct RetryRegime { let maxDelay: TimeInterval } -/// Computes the wait before the next connection attempt using exponential -/// backoff with jitter. A `normal` failure advances the backoff on the current -/// regime. An `unexpected` failure moves to the extended regime and stays there -/// until `reset()`. It is not thread safe. +/// Computes the wait before the next connection attempt using exponential backoff with jitter. +/// A `normal` failure advances the backoff on the current regime. +/// An `unexpected` failure moves to the extended regime and stays there until enough consecutive successes clear the backoff. +/// It is not thread safe. final class RetryState { + private struct Constants { + static let streamingNormalInitialDelay: TimeInterval = 1 + static let streamingNormalMaxDelay: TimeInterval = 30 + static let extendedInitialDelay: TimeInterval = 5 * 60 + static let extendedMaxDelay: TimeInterval = 60 * 60 + // The consecutive successes that clear the backoff. + static let streamingResetThreshold = 1 + static let pollingResetThreshold = 2 + } + private let normal: RetryRegime private let extended: RetryRegime private let minDelay: TimeInterval + private let resetThreshold: Int private var inExtendedRegime = false private var attempts = 0 + private var consecutiveSuccesses = 0 - init(normal: RetryRegime, extended: RetryRegime, minDelay: TimeInterval) { + init(normal: RetryRegime, extended: RetryRegime, minDelay: TimeInterval, resetThreshold: Int) { self.normal = normal self.extended = extended self.minDelay = minDelay + self.resetThreshold = resetThreshold } - /// Streaming. Normal backoff runs from 1 second up to 30 seconds. Extended - /// runs from 5 minutes up to 1 hour. There is no wait floor. static func forStreaming() -> RetryState { RetryState( - normal: RetryRegime(initialDelay: 1, maxDelay: 30), - extended: RetryRegime(initialDelay: 5 * 60, maxDelay: 60 * 60), - minDelay: 0) + normal: RetryRegime(initialDelay: Constants.streamingNormalInitialDelay, + maxDelay: Constants.streamingNormalMaxDelay), + extended: RetryRegime(initialDelay: Constants.extendedInitialDelay, + maxDelay: Constants.extendedMaxDelay), + minDelay: 0, + resetThreshold: Constants.streamingResetThreshold) } - /// Polling. Normal retries at `pollInterval`. An unexpected failure backs off - /// from 5 minutes up to 1 hour. static func forPolling(pollInterval: TimeInterval) -> RetryState { RetryState( normal: RetryRegime(initialDelay: pollInterval, maxDelay: pollInterval), - extended: RetryRegime(initialDelay: max(5 * 60, pollInterval), - maxDelay: max(60 * 60, pollInterval)), - minDelay: pollInterval) + extended: RetryRegime(initialDelay: max(Constants.extendedInitialDelay, pollInterval), + maxDelay: max(Constants.extendedMaxDelay, pollInterval)), + minDelay: pollInterval, + resetThreshold: Constants.pollingResetThreshold) } func recordFailure(unexpected: Bool) { + consecutiveSuccesses = 0 if unexpected && !inExtendedRegime { inExtendedRegime = true attempts = 1 @@ -54,13 +68,26 @@ final class RetryState { attempts += 1 } - func reset() { + /// Records a successful operation. The backoff clears after enough consecutive successes. + func recordSuccess() { + consecutiveSuccesses += 1 + if consecutiveSuccesses >= resetThreshold { + // The service has recovered, so the next failure starts over in the normal regime instead of the extended one. + reset() + } + } + + private func reset() { inExtendedRegime = false attempts = 0 + consecutiveSuccesses = 0 } func nextDelay() -> TimeInterval { - let regime = inExtendedRegime ? extended : normal + // The extended regime applies only after a failure. + // A success uses the normal regime, even when the extended regime has not cleared. + let useExtended = inExtendedRegime && consecutiveSuccesses == 0 + let regime = useExtended ? extended : normal let exponent = Double(max(0, attempts - 1)) let delay = min(regime.maxDelay, regime.initialDelay * pow(2, exponent)) return max(minDelay, delay - Double.random(in: 0...(delay / 2))) diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift index 94385b89..a1c1b59f 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift @@ -35,8 +35,8 @@ final class RetryStateSpec: XCTestCase { assertDelay(retry, inClosedRange: range) } - // A reset returns to the normal regime, backing off from the initial delay again. - retry.reset() + // One healthy stream clears the backoff, so the next failure backs off from the initial delay again. + retry.recordSuccess() retry.recordFailure(unexpected: false) assertDelay(retry, inClosedRange: 0.5...1) } @@ -60,12 +60,29 @@ final class RetryStateSpec: XCTestCase { assertDelay(retry, inClosedRange: range) } - // A reset returns to the poll interval. - retry.reset() + // Two successful polls clear the backoff, so the next failure holds the poll interval again. + retry.recordSuccess() + retry.recordSuccess() retry.recordFailure(unexpected: false) assertDelay(retry, inClosedRange: 30...30) } + func testPollingSuccessUsesPollIntervalBeforeBackoffClears() { + let retry = RetryState.forPolling(pollInterval: 30) + + // An unexpected failure switches to the extended regime. + retry.recordFailure(unexpected: true) + assertDelay(retry, inClosedRange: 150...300) + + // One success is short of the reset threshold, but the next poll still waits only the poll interval. + retry.recordSuccess() + assertDelay(retry, inClosedRange: 30...30) + + // The backoff has not cleared, so a failure returns to the extended regime. + retry.recordFailure(unexpected: false) + assertDelay(retry, inClosedRange: 300...600) + } + func testPollingWaitFloorRaisesToPollInterval() { // A poll interval above the 5 minute extended base floors every delay at the interval. let retry = RetryState.forPolling(pollInterval: 600) From a4d838ee1e312f72890e606a6c1e783cf9c52409 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 28 Sep 2026 15:16:39 -0700 Subject: [PATCH 10/12] docs: Reflow the retry comments to one sentence per line --- .../LaunchDarkly/ServiceObjects/FlagSynchronizer.swift | 10 ++++------ .../ServiceObjects/RetryStateSpec.swift | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index e1e13bbc..b3bc8303 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -73,8 +73,7 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { let streamingMode: LDStreamingMode - // Not thread-safe, but only accessed on one queue per instance: the event source callback queue for - // streaming, or isOnlineQueue for polling. + // Not thread-safe, but only accessed on one queue per instance: the event source callback queue for streaming, or isOnlineQueue for polling. private let retryState: RetryState private static let healthyResetThreshold: TimeInterval = 60 @@ -478,15 +477,14 @@ extension FlagSynchronizer { processFlagResponse(serviceResponse: serviceResponse) } - // connectedAt is set on the event source callback queue; a test uses this on the - // thread where it drives eventSourceErrorHandler. + // connectedAt is set on the event source callback queue. + // A test uses this on the thread where it drives eventSourceErrorHandler. var testConnectedAt: Date? { get { connectedAt } set { connectedAt = newValue } } - // Marks the synchronizer online without starting a data source, so a test can - // drive pollDidComplete without real polls racing the assertions. + // Marks the synchronizer online without starting a data source, so a test can drive pollDidComplete without real polls racing the assertions. func testForceOnline() { isOnlineQueue.sync { _isOnline = true } } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift index a1c1b59f..3340540f 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift @@ -4,8 +4,8 @@ import XCTest @testable import LaunchDarkly final class RetryStateSpec: XCTestCase { - // nextDelay applies random jitter. Each result is asserted to fall within a - // range over many samples rather than against a fixed value. + // nextDelay applies random jitter. + // Each result is asserted to fall within a range over many samples rather than against a fixed value. private func assertDelay(_ retry: RetryState, inClosedRange range: ClosedRange, file: StaticString = #filePath, From 80a95f47fbbc416d909eff546e251ee17686c85b Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 28 Sep 2026 17:01:11 -0700 Subject: [PATCH 11/12] refactor: Split RetryState into streaming and polling types --- .../ServiceObjects/FlagSynchronizer.swift | 27 ++-- .../ServiceObjects/RetryState.swift | 115 +++++++++--------- .../ServiceObjects/RetryStateSpec.swift | 24 ++-- 3 files changed, 91 insertions(+), 75 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index b3bc8303..313e3813 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -73,8 +73,10 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { let streamingMode: LDStreamingMode - // Not thread-safe, but only accessed on one queue per instance: the event source callback queue for streaming, or isOnlineQueue for polling. - private let retryState: RetryState + // Only accessed on the event source callback queue. + private let streamingRetry: StreamingRetryState + // Only accessed on isOnlineQueue. + private let pollingRetry: PollingRetryState private static let healthyResetThreshold: TimeInterval = 60 var isOnline: Bool { @@ -109,9 +111,8 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { onSyncComplete: FlagSyncCompleteClosure?) { self.streamingMode = streamingMode self.pollingInterval = pollingInterval - self.retryState = (streamingMode == .streaming) - ? RetryState.forStreaming() - : RetryState.forPolling(pollInterval: pollingInterval) + self.streamingRetry = StreamingRetryState() + self.pollingRetry = PollingRetryState(pollInterval: pollingInterval) self.useReport = useReport self.lastCachedRequestedTime = lastUpdated self.service = service @@ -226,11 +227,11 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { guard let self = self, self._isOnline, self.streamingMode == .polling else { return } if failed { - self.retryState.recordFailure(unexpected: unexpected) + self.pollingRetry.recordFailure(unexpected: unexpected) } else { - self.retryState.recordSuccess() + self.pollingRetry.recordSuccess() } - self.scheduleNextPoll(after: self.retryState.nextDelay()) + self.scheduleNextPoll(after: self.pollingRetry.nextDelay()) } } @@ -342,14 +343,14 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { } eventSourceStarted = now - // A stream that stayed up long enough before failing is a success. + // A stream that stayed up long enough before failing clears the backoff. if let connectedAt = connectedAt, now.timeIntervalSince(connectedAt) >= FlagSynchronizer.healthyResetThreshold { - retryState.recordSuccess() + streamingRetry.reset() } connectedAt = nil - retryState.recordFailure(unexpected: SynchronizingError.streamError(error).isTerminal) - let delay = retryState.nextDelay() + streamingRetry.recordFailure(unexpected: SynchronizingError.streamError(error).isTerminal) + let delay = streamingRetry.nextDelay() os_log("%s stream error; reconnecting in %.3fs. error: %s", log: service.config.logger, type: .debug, typeName(and: #function), delay, String(describing: error)) reportSyncComplete(.error(.streamError(error))) @@ -495,7 +496,7 @@ extension FlagSynchronizer { // Reads the poll delay on isOnlineQueue so it serializes after a pending pollDidComplete. var testNextPollDelay: TimeInterval { - isOnlineQueue.sync { retryState.nextDelay() } + isOnlineQueue.sync { pollingRetry.nextDelay() } } func testReconnect() { diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift index 85c747c1..749198b4 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/RetryState.swift @@ -1,5 +1,14 @@ import Foundation +private struct Constants { + static let streamingNormalInitialDelay: TimeInterval = 1 + static let streamingNormalMaxDelay: TimeInterval = 30 + static let extendedInitialDelay: TimeInterval = 5 * 60 + static let extendedMaxDelay: TimeInterval = 60 * 60 + // The consecutive successful polls that clear the backoff. + static let pollingResetThreshold = 2 +} + /// The delay bounds for one retry regime. struct RetryRegime { /// The base delay for the first attempt in this regime. @@ -8,54 +17,57 @@ struct RetryRegime { let maxDelay: TimeInterval } -/// Computes the wait before the next connection attempt using exponential backoff with jitter. -/// A `normal` failure advances the backoff on the current regime. -/// An `unexpected` failure moves to the extended regime and stays there until enough consecutive successes clear the backoff. -/// It is not thread safe. -final class RetryState { - private struct Constants { - static let streamingNormalInitialDelay: TimeInterval = 1 - static let streamingNormalMaxDelay: TimeInterval = 30 - static let extendedInitialDelay: TimeInterval = 5 * 60 - static let extendedMaxDelay: TimeInterval = 60 * 60 - // The consecutive successes that clear the backoff. - static let streamingResetThreshold = 1 - static let pollingResetThreshold = 2 - } +/// The exponential backoff for an attempt, less a random amount up to half of it. +private func jitteredBackoff(_ regime: RetryRegime, attempts: Int) -> TimeInterval { + let delay = min(regime.maxDelay, regime.initialDelay * pow(2, Double(max(0, attempts - 1)))) + return delay - Double.random(in: 0...(delay / 2)) +} - private let normal: RetryRegime - private let extended: RetryRegime - private let minDelay: TimeInterval - private let resetThreshold: Int +/// Computes the wait before the streaming data source reconnects. +/// It is not thread safe. +final class StreamingRetryState { + private let normal = RetryRegime(initialDelay: Constants.streamingNormalInitialDelay, + maxDelay: Constants.streamingNormalMaxDelay) + private let extended = RetryRegime(initialDelay: Constants.extendedInitialDelay, + maxDelay: Constants.extendedMaxDelay) private var inExtendedRegime = false private var attempts = 0 - private var consecutiveSuccesses = 0 - init(normal: RetryRegime, extended: RetryRegime, minDelay: TimeInterval, resetThreshold: Int) { - self.normal = normal - self.extended = extended - self.minDelay = minDelay - self.resetThreshold = resetThreshold + func recordFailure(unexpected: Bool) { + if unexpected && !inExtendedRegime { + inExtendedRegime = true + attempts = 1 + return + } + attempts += 1 + } + + /// Clears the backoff. The next failure starts over in the normal regime. + func reset() { + inExtendedRegime = false + attempts = 0 } - static func forStreaming() -> RetryState { - RetryState( - normal: RetryRegime(initialDelay: Constants.streamingNormalInitialDelay, - maxDelay: Constants.streamingNormalMaxDelay), - extended: RetryRegime(initialDelay: Constants.extendedInitialDelay, - maxDelay: Constants.extendedMaxDelay), - minDelay: 0, - resetThreshold: Constants.streamingResetThreshold) + func nextDelay() -> TimeInterval { + jitteredBackoff(inExtendedRegime ? extended : normal, attempts: attempts) } +} + +/// Computes the wait before the polling data source polls again. +/// It is not thread safe. +final class PollingRetryState { + private let pollInterval: TimeInterval + private let extended: RetryRegime - static func forPolling(pollInterval: TimeInterval) -> RetryState { - RetryState( - normal: RetryRegime(initialDelay: pollInterval, maxDelay: pollInterval), - extended: RetryRegime(initialDelay: max(Constants.extendedInitialDelay, pollInterval), - maxDelay: max(Constants.extendedMaxDelay, pollInterval)), - minDelay: pollInterval, - resetThreshold: Constants.pollingResetThreshold) + private var inExtendedRegime = false + private var attempts = 0 + private var consecutiveSuccesses = 0 + + init(pollInterval: TimeInterval) { + self.pollInterval = pollInterval + self.extended = RetryRegime(initialDelay: max(Constants.extendedInitialDelay, pollInterval), + maxDelay: max(Constants.extendedMaxDelay, pollInterval)) } func recordFailure(unexpected: Bool) { @@ -68,28 +80,21 @@ final class RetryState { attempts += 1 } - /// Records a successful operation. The backoff clears after enough consecutive successes. + /// Records a successful poll. The backoff clears after enough consecutive successes. func recordSuccess() { consecutiveSuccesses += 1 - if consecutiveSuccesses >= resetThreshold { - // The service has recovered, so the next failure starts over in the normal regime instead of the extended one. - reset() - } - } - - private func reset() { + guard consecutiveSuccesses >= Constants.pollingResetThreshold + else { return } + // The service has recovered, so the next unexpected failure starts the backoff over. inExtendedRegime = false attempts = 0 - consecutiveSuccesses = 0 } func nextDelay() -> TimeInterval { - // The extended regime applies only after a failure. - // A success uses the normal regime, even when the extended regime has not cleared. - let useExtended = inExtendedRegime && consecutiveSuccesses == 0 - let regime = useExtended ? extended : normal - let exponent = Double(max(0, attempts - 1)) - let delay = min(regime.maxDelay, regime.initialDelay * pow(2, exponent)) - return max(minDelay, delay - Double.random(in: 0...(delay / 2))) + // A normal failure does not grow the delay, and a success shows the service works. + // Only an unresolved unexpected failure waits longer than the poll interval. + guard inExtendedRegime, consecutiveSuccesses == 0 + else { return pollInterval } + return max(pollInterval, jitteredBackoff(extended, attempts: attempts)) } } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift index 3340540f..c3cec266 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/RetryStateSpec.swift @@ -6,7 +6,17 @@ import XCTest final class RetryStateSpec: XCTestCase { // nextDelay applies random jitter. // Each result is asserted to fall within a range over many samples rather than against a fixed value. - private func assertDelay(_ retry: RetryState, + private func assertDelay(_ retry: StreamingRetryState, + inClosedRange range: ClosedRange, + file: StaticString = #filePath, + line: UInt = #line) { + for _ in 0..<50 { + let delay = retry.nextDelay() + XCTAssertTrue(range.contains(delay), "\(delay) is outside \(range)", file: file, line: line) + } + } + + private func assertDelay(_ retry: PollingRetryState, inClosedRange range: ClosedRange, file: StaticString = #filePath, line: UInt = #line) { @@ -17,7 +27,7 @@ final class RetryStateSpec: XCTestCase { } func testStreamingRetryLifecycle() { - let retry = RetryState.forStreaming() + let retry = StreamingRetryState() // Normal failures back off from the initial delay and double to the 30s ceiling. for range in [0.5...1, 1...2, 2...4, 4...8, 8...16, 15...30, 15...30] as [ClosedRange] { @@ -35,14 +45,14 @@ final class RetryStateSpec: XCTestCase { assertDelay(retry, inClosedRange: range) } - // One healthy stream clears the backoff, so the next failure backs off from the initial delay again. - retry.recordSuccess() + // A reset returns to the normal regime, so the next failure backs off from the initial delay again. + retry.reset() retry.recordFailure(unexpected: false) assertDelay(retry, inClosedRange: 0.5...1) } func testPollingRetryLifecycle() { - let retry = RetryState.forPolling(pollInterval: 30) + let retry = PollingRetryState(pollInterval: 30) // Normal failures hold the poll interval with no escalation. for _ in 0..<3 { @@ -68,7 +78,7 @@ final class RetryStateSpec: XCTestCase { } func testPollingSuccessUsesPollIntervalBeforeBackoffClears() { - let retry = RetryState.forPolling(pollInterval: 30) + let retry = PollingRetryState(pollInterval: 30) // An unexpected failure switches to the extended regime. retry.recordFailure(unexpected: true) @@ -85,7 +95,7 @@ final class RetryStateSpec: XCTestCase { func testPollingWaitFloorRaisesToPollInterval() { // A poll interval above the 5 minute extended base floors every delay at the interval. - let retry = RetryState.forPolling(pollInterval: 600) + let retry = PollingRetryState(pollInterval: 600) retry.recordFailure(unexpected: false) assertDelay(retry, inClosedRange: 600...600) retry.recordFailure(unexpected: true) From 147d849878f5dd2154d8b1a17487156d765af671 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 29 Sep 2026 14:02:52 -0700 Subject: [PATCH 12/12] fix: Bump swift-eventsource to 3.4.0 for server-initiated stream closes --- LaunchDarkly.podspec | 2 +- LaunchDarkly.xcodeproj/project.pbxproj | 2 +- Package.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LaunchDarkly.podspec b/LaunchDarkly.podspec index b25f591b..e2378ec2 100644 --- a/LaunchDarkly.podspec +++ b/LaunchDarkly.podspec @@ -38,6 +38,6 @@ Pod::Spec.new do |ld| ld.swift_version = '5.0' ld.subspec 'Core' do |es| - es.dependency 'LDSwiftEventSource', '3.3.1' + es.dependency 'LDSwiftEventSource', '3.4.0' end end diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 10d0726f..21493ebd 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -2312,7 +2312,7 @@ repositoryURL = "https://github.com/LaunchDarkly/swift-eventsource.git"; requirement = { kind = exactVersion; - version = 3.3.1; + version = 3.4.0; }; }; B4903D9624BD61B200F087C4 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */ = { diff --git a/Package.swift b/Package.swift index bd1cd957..fb07ab39 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( .package(url: "https://github.com/Quick/Quick.git", .exact("4.0.0")), .package(url: "https://github.com/Quick/Nimble.git", .exact("9.2.1")), .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting", .exact("2.1.2")), - .package(name: "LDSwiftEventSource", url: "https://github.com/LaunchDarkly/swift-eventsource.git", .exact("3.3.1")), + .package(name: "LDSwiftEventSource", url: "https://github.com/LaunchDarkly/swift-eventsource.git", .exact("3.4.0")), ], targets: [ .target(