diff --git a/Analytics-CSharp/Segment/Analytics/Configuration.cs b/Analytics-CSharp/Segment/Analytics/Configuration.cs index 79de436..c2aa395 100644 --- a/Analytics-CSharp/Segment/Analytics/Configuration.cs +++ b/Analytics-CSharp/Segment/Analytics/Configuration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Segment.Analytics.Policies; +using Segment.Analytics.Retry; using Segment.Analytics.Utilities; using Segment.Concurrent; using Segment.Serialization; @@ -47,6 +48,21 @@ private set public IEventPipelineProvider EventPipelineProvider { get; } + /// + /// HTTP retry configuration for rate limiting and exponential backoff. Defaults to + /// null. Set it before constructing Analytics, e.g. + /// new Configuration("writeKey") { HttpConfig = new HttpConfig(...) }. + /// Mirrors analytics-kotlin's mutable Configuration.httpConfig. + /// + /// This sets the pipeline's starting configuration only. CDN settings take precedence: + /// any settings payload carrying an httpConfig key replaces this value, and a CDN + /// payload is treated as enabling a subsystem unless it says "enabled": "false". + /// A payload with no httpConfig key leaves this value in effect. This matches the + /// behaviour of analytics-kotlin and analytics-swift. + /// + /// + public HttpConfig HttpConfig { get; set; } + /// /// Configuration that analytics can use /// diff --git a/Analytics-CSharp/Segment/Analytics/Retry/RetryConfig.cs b/Analytics-CSharp/Segment/Analytics/Retry/RetryConfig.cs index 5be1331..3f83581 100644 --- a/Analytics-CSharp/Segment/Analytics/Retry/RetryConfig.cs +++ b/Analytics-CSharp/Segment/Analytics/Retry/RetryConfig.cs @@ -3,7 +3,7 @@ namespace Segment.Analytics.Retry { - internal class RateLimitConfig + public class RateLimitConfig { public bool Enabled { get; } public int MaxRetryCount { get; } @@ -23,7 +23,7 @@ public RateLimitConfig(bool enabled = false, int maxRetryCount = 100, int maxRet ); } - internal class BackoffConfig + public class BackoffConfig { public bool Enabled { get; } public int MaxRetryCount { get; } @@ -57,7 +57,10 @@ public BackoffConfig( Default4xxBehavior = default4xxBehavior; Default5xxBehavior = default5xxBehavior; UnknownCodeBehavior = unknownCodeBehavior; - StatusCodeOverrides = statusCodeOverrides ?? DefaultStatusCodeOverrides; + // Copy: the property is public, and sharing the static default would let one + // caller's mutation corrupt every BackoffConfig built afterwards in the process. + StatusCodeOverrides = new Dictionary( + statusCodeOverrides ?? DefaultStatusCodeOverrides); } public BackoffConfig Validated() => new BackoffConfig( @@ -109,7 +112,7 @@ public RetryConfig(RateLimitConfig rateLimitConfig = null, BackoffConfig backoff } } - internal class HttpConfig + public class HttpConfig { public RateLimitConfig RateLimitConfig { get; } public BackoffConfig BackoffConfig { get; } diff --git a/Analytics-CSharp/Segment/Analytics/Retry/RetryTypes.cs b/Analytics-CSharp/Segment/Analytics/Retry/RetryTypes.cs index 7a87348..5884cb3 100644 --- a/Analytics-CSharp/Segment/Analytics/Retry/RetryTypes.cs +++ b/Analytics-CSharp/Segment/Analytics/Retry/RetryTypes.cs @@ -6,7 +6,7 @@ internal enum PipelineState RateLimited } - internal enum RetryBehavior + public enum RetryBehavior { Retry, Drop diff --git a/Analytics-CSharp/Segment/Analytics/Utilities/EventPipeline.cs b/Analytics-CSharp/Segment/Analytics/Utilities/EventPipeline.cs index 3056e8a..98c14bd 100644 --- a/Analytics-CSharp/Segment/Analytics/Utilities/EventPipeline.cs +++ b/Analytics-CSharp/Segment/Analytics/Utilities/EventPipeline.cs @@ -48,7 +48,7 @@ public EventPipeline( string apiHost = HTTPClient.DefaultAPIHost) : this(analytics, logTag, apiKey, flushPolicies, apiHost, (HttpConfig)null) { } - internal EventPipeline( + public EventPipeline( Analytics analytics, string logTag, string apiKey, @@ -69,7 +69,9 @@ internal EventPipeline( Running = false; var retryConfig = httpConfig != null - ? new RetryConfig(httpConfig.RateLimitConfig, httpConfig.BackoffConfig) + // Validated(): user-supplied config reaches us unclamped, unlike the + // CDN path which HttpConfigParser already validates. + ? new RetryConfig(httpConfig.RateLimitConfig.Validated(), httpConfig.BackoffConfig.Validated()) : new RetryConfig(); _retryStateMachine = new RetryStateMachine(retryConfig); _retryState = RetryStateStorage.LoadRetryState(_storage); @@ -78,7 +80,7 @@ internal EventPipeline( internal void UpdateHttpConfig(HttpConfig config) { var retryConfig = config != null - ? new RetryConfig(config.RateLimitConfig, config.BackoffConfig) + ? new RetryConfig(config.RateLimitConfig.Validated(), config.BackoffConfig.Validated()) : new RetryConfig(); _retryStateMachine = new RetryStateMachine(retryConfig); } diff --git a/Analytics-CSharp/Segment/Analytics/Utilities/EventPipelineProvider.cs b/Analytics-CSharp/Segment/Analytics/Utilities/EventPipelineProvider.cs index abd376c..137780c 100644 --- a/Analytics-CSharp/Segment/Analytics/Utilities/EventPipelineProvider.cs +++ b/Analytics-CSharp/Segment/Analytics/Utilities/EventPipelineProvider.cs @@ -11,7 +11,8 @@ public IEventPipeline Create(Analytics analytics, string key) return new EventPipeline(analytics, key, analytics.Configuration.WriteKey, analytics.Configuration.FlushPolicies, - analytics.Configuration.ApiHost); + analytics.Configuration.ApiHost, + analytics.Configuration.HttpConfig); } } } \ No newline at end of file diff --git a/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipeline.cs b/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipeline.cs index 4657be9..9c283f3 100644 --- a/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipeline.cs +++ b/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipeline.cs @@ -61,7 +61,7 @@ public SyncEventPipeline( CancellationToken? flushCancellationToken = null) : this(analytics, logTag, apiKey, flushPolicies, apiHost, flushTimeout, flushCancellationToken, null) { } - internal SyncEventPipeline( + public SyncEventPipeline( Analytics analytics, string logTag, string apiKey, @@ -86,7 +86,9 @@ internal SyncEventPipeline( _flushCancellationToken = flushCancellationToken ?? CancellationToken.None; var retryConfig = httpConfig != null - ? new RetryConfig(httpConfig.RateLimitConfig, httpConfig.BackoffConfig) + // Validated(): user-supplied config reaches us unclamped, unlike the + // CDN path which HttpConfigParser already validates. + ? new RetryConfig(httpConfig.RateLimitConfig.Validated(), httpConfig.BackoffConfig.Validated()) : new RetryConfig(); _retryStateMachine = new RetryStateMachine(retryConfig); _retryState = RetryStateStorage.LoadRetryState(_storage); @@ -95,7 +97,7 @@ internal SyncEventPipeline( internal void UpdateHttpConfig(HttpConfig config) { var retryConfig = config != null - ? new RetryConfig(config.RateLimitConfig, config.BackoffConfig) + ? new RetryConfig(config.RateLimitConfig.Validated(), config.BackoffConfig.Validated()) : new RetryConfig(); _retryStateMachine = new RetryStateMachine(retryConfig); } diff --git a/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipelineProvider.cs b/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipelineProvider.cs index 5794677..931a10b 100644 --- a/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipelineProvider.cs +++ b/Analytics-CSharp/Segment/Analytics/Utilities/SyncEventPipelineProvider.cs @@ -22,7 +22,8 @@ public IEventPipeline Create(Analytics analytics, string key) analytics.Configuration.FlushPolicies, analytics.Configuration.ApiHost, _flushTimeout, - _flushCancellationToken); + _flushCancellationToken, + analytics.Configuration.HttpConfig); } } } \ No newline at end of file diff --git a/Tests/Retry/ConfigurationHttpConfigTest.cs b/Tests/Retry/ConfigurationHttpConfigTest.cs new file mode 100644 index 0000000..6e15dc5 --- /dev/null +++ b/Tests/Retry/ConfigurationHttpConfigTest.cs @@ -0,0 +1,130 @@ +using Moq; +using Segment.Analytics; +using Segment.Analytics.Retry; +using Segment.Analytics.Utilities; +using Segment.Serialization; +using Tests.Utils; +using Xunit; + +namespace Tests.Retry +{ + /// + /// Configuration.HttpConfig is the user-facing entry point for retry settings, + /// mirroring Kotlin's Configuration.httpConfig and Swift's .httpConfig(_:). + /// These cover that a config supplied there actually reaches the pipeline's + /// retry state machine; CDN settings still override it later via UpdateHttpConfig. + /// + public class ConfigurationHttpConfigTest + { + private static Analytics CreateAnalytics(HttpConfig httpConfig) + { + Settings? settings = JsonUtility.FromJson( + "{\"integrations\":{\"Segment.io\":{\"apiKey\":\"k\"}},\"plan\":{},\"edgeFunction\":{}}"); + + var mockHttpClient = new Mock(null, null, null); + mockHttpClient.Setup(c => c.Settings()).ReturnsAsync(settings); + + var config = new Configuration( + writeKey: "123", + autoAddSegmentDestination: false, + useSynchronizeDispatcher: true, + flushInterval: 0, + flushAt: 2, + httpClientProvider: new MockHttpClientProvider(mockHttpClient), + storageProvider: new MockStorageProvider(new Mock()) + ) + { + HttpConfig = httpConfig + }; + return new Analytics(config); + } + + [Fact] + public void Configuration_ExposesHttpConfig() + { + var httpConfig = new HttpConfig(backoffConfig: new BackoffConfig(enabled: true, maxRetryCount: 7)); + Analytics analytics = CreateAnalytics(httpConfig); + + Assert.Same(httpConfig, analytics.Configuration.HttpConfig); + } + + [Fact] + public void Configuration_HttpConfigDefaultsToNull() + { + Analytics analytics = CreateAnalytics(null); + + Assert.Null(analytics.Configuration.HttpConfig); + } + + [Fact] + public void EventPipeline_WithoutHttpConfig_IsLegacyMode() + { + Analytics analytics = CreateAnalytics(null); + + var pipeline = (EventPipeline)new EventPipelineProvider().Create(analytics, "key"); + + Assert.True(pipeline._retryStateMachine.IsLegacyMode); + } + + [Fact] + public void EventPipeline_WithHttpConfig_LeavesLegacyMode() + { + Analytics analytics = CreateAnalytics( + new HttpConfig(backoffConfig: new BackoffConfig(enabled: true))); + + var pipeline = (EventPipeline)new EventPipelineProvider().Create(analytics, "key"); + + Assert.False(pipeline._retryStateMachine.IsLegacyMode); + } + + [Fact] + public void SyncEventPipeline_WithoutHttpConfig_IsLegacyMode() + { + Analytics analytics = CreateAnalytics(null); + + var pipeline = (SyncEventPipeline)new SyncEventPipelineProvider().Create(analytics, "key"); + + Assert.True(pipeline._retryStateMachine.IsLegacyMode); + } + + [Fact] + public void BackoffConfig_DoesNotShareTheDefaultOverrideMap() + { + var first = new BackoffConfig(enabled: true); + first.StatusCodeOverrides[500] = RetryBehavior.Drop; + + var second = new BackoffConfig(enabled: true); + + Assert.False(second.StatusCodeOverrides.ContainsKey(500)); + Assert.NotSame(first.StatusCodeOverrides, second.StatusCodeOverrides); + } + + [Fact] + public void UserSuppliedHttpConfig_IsValidatedOnTheWayIn() + { + // maxRetryInterval: 0 is out of range and must clamp to 1 second, exactly as the + // CDN path does via HttpConfigParser. Unvalidated it would schedule the retry at + // currentTime, i.e. no wait at all. + Analytics analytics = CreateAnalytics( + new HttpConfig(rateLimitConfig: new RateLimitConfig(enabled: true, maxRetryInterval: 0))); + + var pipeline = (EventPipeline)new EventPipelineProvider().Create(analytics, "key"); + RetryState state = pipeline._retryStateMachine.HandleResponse( + new RetryState(), + new ResponseInfo(429, retryAfterSeconds: null, batchFile: "b.json", currentTime: 1000)); + + Assert.Equal(2000, state.WaitUntilTime); + } + + [Fact] + public void SyncEventPipeline_WithHttpConfig_LeavesLegacyMode() + { + Analytics analytics = CreateAnalytics( + new HttpConfig(rateLimitConfig: new RateLimitConfig(enabled: true))); + + var pipeline = (SyncEventPipeline)new SyncEventPipelineProvider().Create(analytics, "key"); + + Assert.False(pipeline._retryStateMachine.IsLegacyMode); + } + } +}