From 440803b5526a0956ed663eb628bcba08893cb5c7 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 19 Jan 2026 23:22:20 +0600 Subject: [PATCH 1/5] [FSSDK-12148] event retry addition --- .../OptimizelySDK.Net35.csproj | 3 + .../OptimizelySDK.Net40.csproj | 3 + .../OptimizelySDK.NetStandard16.csproj | 1 + .../OptimizelySDK.NetStandard20.csproj | 3 + .../Dispatcher/HttpClientEventDispatcher45.cs | 93 +++++++++--- .../Dispatcher/WebRequestEventDispatcher35.cs | 141 +++++++++++++----- OptimizelySDK/Event/EventRetryConfig.cs | 48 ++++++ OptimizelySDK/Odp/Constants.cs | 7 +- OptimizelySDK/Odp/OdpEventManager.cs | 21 ++- OptimizelySDK/OptimizelySDK.csproj | 1 + 10 files changed, 252 insertions(+), 69 deletions(-) create mode 100644 OptimizelySDK/Event/EventRetryConfig.cs diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj index 4c3145c2..a8a704e0 100644 --- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj +++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj @@ -145,6 +145,9 @@ Event\WebRequestEventDispatcher35.cs + + Event\EventRetryConfig.cs + Event\LogEvent.cs diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj index 6f2b3f23..017e210c 100644 --- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj +++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj @@ -147,6 +147,9 @@ Event\WebRequestEventDispatcher35.cs + + Event\EventRetryConfig.cs + Event\LogEvent.cs diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index c1ba6d73..18aef612 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -50,6 +50,7 @@ + diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index e41c7fd7..841059bd 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -288,6 +288,9 @@ Event\Dispatcher\WebRequestEventDispatcher35.cs + + Event\EventRetryConfig.cs + Event\Entity\ConversionEvent.cs diff --git a/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs b/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs index dd7b9e0f..5e7c131a 100644 --- a/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs +++ b/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs @@ -1,5 +1,5 @@ /* - * Copyright 2017, 2019, Optimizely + * Copyright 2017, 2019, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ #if !NET35 && !NET40 using System; +using System.Net; using System.Net.Http; using System.Threading.Tasks; using OptimizelySDK.Logger; @@ -41,37 +42,89 @@ static HttpClientEventDispatcher45() } /// - /// Dispatch an Event asynchronously + /// Dispatch an Event asynchronously with retry and exponential backoff. + /// Retries on 5xx server errors and network failures. /// - private async void DispatchEventAsync(LogEvent logEvent) + private async Task DispatchEventAsync(LogEvent logEvent) { - try + var attemptNumber = 0; + var backoffMs = EventRetryConfig.INITIAL_BACKOFF_MS; + var maxAttempts = 1 + EventRetryConfig.MAX_RETRIES; // 1 initial + retries + + while (attemptNumber < maxAttempts) { - var json = logEvent.GetParamsAsJson(); - var request = new HttpRequestMessage + HttpResponseMessage response = null; + try { - RequestUri = new Uri(logEvent.Url), - Method = HttpMethod.Post, - // The Content-Type header applies to the Content, not the Request itself - Content = - new StringContent(json, System.Text.Encoding.UTF8, "application/json"), - }; + var json = logEvent.GetParamsAsJson(); + var request = new HttpRequestMessage + { + RequestUri = new Uri(logEvent.Url), + Method = HttpMethod.Post, + // The Content-Type header applies to the Content, not the Request itself + Content = + new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }; - foreach (var h in logEvent.Headers) - { - if (h.Key.ToLower() != "content-type") + foreach (var h in logEvent.Headers) { - request.Content.Headers.Add(h.Key, h.Value); + if (h.Key.ToLower() != "content-type") + { + request.Content.Headers.Add(h.Key, h.Value); + } } + + response = await Client.SendAsync(request).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // Success - exit the retry loop + return; } + catch (HttpRequestException ex) + { + var statusCode = response?.StatusCode; + var shouldRetry = ShouldRetry(statusCode); - var result = await Client.SendAsync(request); - result.EnsureSuccessStatusCode(); + if (shouldRetry && attemptNumber < maxAttempts - 1) + { + await Task.Delay(backoffMs).ConfigureAwait(false); + backoffMs = Math.Min(EventRetryConfig.MAX_BACKOFF_MS, + (int)(backoffMs * EventRetryConfig.BACKOFF_MULTIPLIER)); + attemptNumber++; + } + else + { + Logger.Log(LogLevel.ERROR, + $"Error Dispatching Event after {attemptNumber + 1} attempt(s): {ex.GetAllMessages()}"); + return; + } + } + catch (Exception ex) + { + // For non-HTTP exceptions, log and don't retry + Logger.Log(LogLevel.ERROR, $"Error Dispatching Event: {ex.GetAllMessages()}"); + return; + } } - catch (Exception ex) + } + + /// + /// Determines whether a request should be retried based on HTTP status code. + /// Retries on 5xx server errors and network failures (null status code). + /// + /// The HTTP status code, or null for network failures + /// True if the request should be retried + private static bool ShouldRetry(HttpStatusCode? statusCode) + { + // Retry on network failures (no response) + if (statusCode == null) { - Logger.Log(LogLevel.ERROR, $"Error Dispatching Event: {ex.GetAllMessages()}"); + return true; } + + // Retry on 5xx server errors + var code = (int)statusCode.Value; + return code >= 500 && code < 600; } /// diff --git a/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs b/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs index f1c282c9..d6d0f7d6 100644 --- a/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs +++ b/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs @@ -1,5 +1,5 @@ /* - * Copyright 2017, Optimizely + * Copyright 2017, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,73 +18,132 @@ using System; using System.IO; using System.Net; +using System.Threading; +using OptimizelySDK.Logger; +using OptimizelySDK.Utils; namespace OptimizelySDK.Event.Dispatcher { public class WebRequestClientEventDispatcher35 : IEventDispatcher { - // TODO Catch and Log Errors public Logger.ILogger Logger { get; set; } - private HttpWebRequest Request = null; - /// - /// Dispatch the Event + /// Dispatch the Event with retry and exponential backoff. /// The call will not wait for the result, it returns after sending (fire and forget) /// But it does get called back asynchronously when the response comes and handles /// /// public void DispatchEvent(LogEvent logEvent) { - Request = (HttpWebRequest)WebRequest.Create(logEvent.Url); + ThreadPool.QueueUserWorkItem(_ => DispatchWithRetry(logEvent)); + } - Request.UserAgent = "Optimizely-csharp-SDKv01"; - Request.Method = logEvent.HttpVerb; + /// + /// Dispatch event with retry logic and exponential backoff. + /// + private void DispatchWithRetry(LogEvent logEvent) + { + var attemptNumber = 0; + var backoffMs = EventRetryConfig.INITIAL_BACKOFF_MS; + var maxAttempts = 1 + EventRetryConfig.MAX_RETRIES; - foreach (var h in logEvent.Headers) + while (attemptNumber < maxAttempts) { - if (!WebHeaderCollection.IsRestricted(h.Key)) + HttpWebRequest request = null; + HttpWebResponse response = null; + try { - Request.Headers[h.Key] = h.Value; - } - } + request = (HttpWebRequest)WebRequest.Create(logEvent.Url); + request.UserAgent = "Optimizely-csharp-SDKv01"; + request.Method = logEvent.HttpVerb; - Request.ContentType = "application/json"; + foreach (var h in logEvent.Headers) + { + if (!WebHeaderCollection.IsRestricted(h.Key)) + { + request.Headers[h.Key] = h.Value; + } + } - using (var streamWriter = new StreamWriter(Request.GetRequestStream())) - { - streamWriter.Write(logEvent.GetParamsAsJson()); - streamWriter.Flush(); - streamWriter.Close(); - } + request.ContentType = "application/json"; - var result = - Request.BeginGetResponse(new AsyncCallback(FinaliseHttpAsyncRequest), this); - } + using (var streamWriter = new StreamWriter(request.GetRequestStream())) + { + streamWriter.Write(logEvent.GetParamsAsJson()); + streamWriter.Flush(); + streamWriter.Close(); + } - private static void FinaliseHttpAsyncRequest(IAsyncResult result) - { - var _this = (WebRequestClientEventDispatcher35)result.AsyncState; - _this.FinalizeRequest(result); - } + response = (HttpWebResponse)request.GetResponse(); - private void FinalizeRequest(IAsyncResult result) - { - var response = (HttpWebResponse)Request.EndGetResponse(result); + if (response.StatusCode == HttpStatusCode.OK) + { + using (var responseStream = response.GetResponseStream()) + using (var responseReader = new StreamReader(responseStream, System.Text.Encoding.UTF8)) + { + responseReader.ReadToEnd(); + } + // Success - exit the retry loop + return; + } + } + catch (WebException ex) + { + var httpResponse = ex.Response as HttpWebResponse; + var shouldRetry = ShouldRetry(httpResponse?.StatusCode); - if (response.StatusCode == HttpStatusCode.OK) - { - // Read the results, even though we don't need it. - var responseStream = response.GetResponseStream(); - var streamEncoder = System.Text.Encoding.UTF8; - var responseReader = new StreamReader(responseStream, streamEncoder); - var data = responseReader.ReadToEnd(); + if (shouldRetry && attemptNumber < maxAttempts - 1) + { + Thread.Sleep(backoffMs); + backoffMs = Math.Min(EventRetryConfig.MAX_BACKOFF_MS, + (int)(backoffMs * EventRetryConfig.BACKOFF_MULTIPLIER)); + attemptNumber++; + } + else + { + LogMessage(LogLevel.ERROR, + string.Format("Error Dispatching Event after {0} attempt(s): {1}", + attemptNumber + 1, ex.GetAllMessages())); + return; + } + } + catch (Exception ex) + { + // For non-web exceptions, log and don't retry + LogMessage(LogLevel.ERROR, "Error Dispatching Event: " + ex.GetAllMessages()); + return; + } + finally + { + response?.Close(); + } } - else + } + + /// + /// Determines whether a request should be retried based on HTTP status code. + /// Retries on 5xx server errors and network failures (null status code). + /// + private static bool ShouldRetry(HttpStatusCode? statusCode) + { + // Retry on network failures (no response) + if (statusCode == null) { - // TODO: Add Logger and capture exception - //throw new Exception(string.Format("Response Not Valid {0}", response.StatusCode)); + return true; } + + // Retry on 5xx server errors + var code = (int)statusCode.Value; + return code >= 500 && code < 600; + } + + /// + /// Helper method to log messages safely when Logger might be null. + /// + private void LogMessage(LogLevel level, string message) + { + Logger?.Log(level, message); } } } diff --git a/OptimizelySDK/Event/EventRetryConfig.cs b/OptimizelySDK/Event/EventRetryConfig.cs new file mode 100644 index 00000000..7ee84dbb --- /dev/null +++ b/OptimizelySDK/Event/EventRetryConfig.cs @@ -0,0 +1,48 @@ +/* + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace OptimizelySDK.Event +{ + /// + /// Configuration constants for retrying event dispatch requests with exponential backoff. + /// Per ticket requirements: + /// - Max 3 total attempts (1 initial + 2 retries) + /// - Start at 200ms, exponentially grow to max 1 second + /// + public static class EventRetryConfig + { + /// + /// Maximum number of retry attempts after the initial attempt fails. + /// Total attempts = 1 (initial) + MAX_RETRIES = 3 + /// + public const int MAX_RETRIES = 2; + + /// + /// Initial backoff delay in milliseconds before the first retry. + /// + public const int INITIAL_BACKOFF_MS = 200; + + /// + /// Maximum backoff delay in milliseconds between retries. + /// + public const int MAX_BACKOFF_MS = 1000; + + /// + /// Multiplier applied to the backoff delay after each retry. + /// + public const double BACKOFF_MULTIPLIER = 2.0; + } +} diff --git a/OptimizelySDK/Odp/Constants.cs b/OptimizelySDK/Odp/Constants.cs index a4ad8b0d..71a91f5d 100644 --- a/OptimizelySDK/Odp/Constants.cs +++ b/OptimizelySDK/Odp/Constants.cs @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Optimizely + * Copyright 2022-2023, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,11 +77,6 @@ public static class Constants /// public const string ODP_SEND_FAILURE_MESSAGE = "ODP event send failed"; - /// - /// Maximum attempts to retry ODP communication - /// - public const int MAX_RETRIES = 3; - /// /// Default ODP batch size /// diff --git a/OptimizelySDK/Odp/OdpEventManager.cs b/OptimizelySDK/Odp/OdpEventManager.cs index cc494124..9f224b2b 100644 --- a/OptimizelySDK/Odp/OdpEventManager.cs +++ b/OptimizelySDK/Odp/OdpEventManager.cs @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023, Optimizely + * Copyright 2022-2023, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ using System.Linq; using System.Threading; using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Event; using OptimizelySDK.Logger; using OptimizelySDK.Odp.Entity; using OptimizelySDK.Utils; @@ -242,12 +243,28 @@ private void FlushQueue() { bool shouldRetry; var attemptNumber = 0; + var maxAttempts = 1 + EventRetryConfig.MAX_RETRIES; // 1 initial + retries = 3 total + var backoffMs = EventRetryConfig.INITIAL_BACKOFF_MS; do { shouldRetry = _odpEventApiManager.SendEvents(_odpConfig.ApiKey, _odpConfig.ApiHost, toProcessBatch); + + if (shouldRetry && attemptNumber < maxAttempts - 1) + { + Thread.Sleep(backoffMs); + backoffMs = Math.Min(EventRetryConfig.MAX_BACKOFF_MS, + (int)(backoffMs * EventRetryConfig.BACKOFF_MULTIPLIER)); + } + attemptNumber += 1; - } while (shouldRetry && attemptNumber < Constants.MAX_RETRIES); + } while (shouldRetry && attemptNumber < maxAttempts); + + if (shouldRetry) + { + _logger.Log(LogLevel.ERROR, + $"ODP event dispatch failed after {attemptNumber} attempt(s)."); + } } catch (Exception e) { diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index df6c0c1c..a0ae7ba4 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -144,6 +144,7 @@ + From 4c3c80b199fc13ad75061ef9b5c64dc51f1209b2 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 19 Jan 2026 23:48:59 +0600 Subject: [PATCH 2/5] [FSSDK-12148] format fix --- .../OdpTests/OdpEventManagerTests.cs | 5 ++- .../Dispatcher/HttpClientEventDispatcher45.cs | 43 ++++++++++--------- .../Dispatcher/WebRequestEventDispatcher35.cs | 23 +++++----- OptimizelySDK/Event/EventRetryConfig.cs | 20 ++++----- OptimizelySDK/Odp/OdpEventManager.cs | 6 +-- 5 files changed, 52 insertions(+), 45 deletions(-) diff --git a/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs b/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs index 1b9f8a0c..cc606667 100644 --- a/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs +++ b/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs @@ -547,6 +547,9 @@ public void ShouldDispatchEventsWithCorrectPayload() [Test] public void ShouldRetryFailedEvents() { + // With exponential backoff: 4 events × 3 attempts each × (200ms + 400ms) delays = ~2400ms+ + // Need longer timeout to account for backoff delays + const int RETRY_TEST_TIMEOUT_MS = 5000; var cde = new CountdownEvent(12); _mockApiManager.Setup(a => a.SendEvents(It.IsAny(), It.IsAny(), @@ -566,7 +569,7 @@ public void ShouldRetryFailedEvents() eventManager.SendEvent(MakeEvent(i)); } - cde.Wait(MAX_COUNT_DOWN_EVENT_WAIT_MS); + cde.Wait(RETRY_TEST_TIMEOUT_MS); // retry 3x (default) 4 events (batches of 1) = 12 calls to attempt to process _mockApiManager.Verify( diff --git a/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs b/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs index 5e7c131a..168297b7 100644 --- a/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs +++ b/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright 2017, 2019, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -18,6 +18,7 @@ using System; using System.Net; using System.Net.Http; +using System.Text; using System.Threading.Tasks; using OptimizelySDK.Logger; using OptimizelySDK.Utils; @@ -26,24 +27,34 @@ namespace OptimizelySDK.Event.Dispatcher { public class HttpClientEventDispatcher45 : IEventDispatcher { - public ILogger Logger { get; set; } = new DefaultLogger(); - /// - /// HTTP client object. + /// HTTP client object. /// private static readonly HttpClient Client; /// - /// Constructor for initializing static members. + /// Constructor for initializing static members. /// static HttpClientEventDispatcher45() { Client = new HttpClient(); } + public ILogger Logger { get; set; } = new DefaultLogger(); + /// - /// Dispatch an Event asynchronously with retry and exponential backoff. - /// Retries on 5xx server errors and network failures. + /// Dispatch an event Asynchronously by creating a new task and calls the + /// Async version of DispatchEvent + /// This is a "Fire and Forget" option + /// + public void DispatchEvent(LogEvent logEvent) + { + Task.Run(() => DispatchEventAsync(logEvent)); + } + + /// + /// Dispatch an Event asynchronously with retry and exponential backoff. + /// Retries on 5xx server errors and network failures. /// private async Task DispatchEventAsync(LogEvent logEvent) { @@ -63,7 +74,7 @@ private async Task DispatchEventAsync(LogEvent logEvent) Method = HttpMethod.Post, // The Content-Type header applies to the Content, not the Request itself Content = - new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + new StringContent(json, Encoding.UTF8, "application/json"), }; foreach (var h in logEvent.Headers) @@ -76,7 +87,7 @@ private async Task DispatchEventAsync(LogEvent logEvent) response = await Client.SendAsync(request).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - + // Success - exit the retry loop return; } @@ -109,8 +120,8 @@ private async Task DispatchEventAsync(LogEvent logEvent) } /// - /// Determines whether a request should be retried based on HTTP status code. - /// Retries on 5xx server errors and network failures (null status code). + /// Determines whether a request should be retried based on HTTP status code. + /// Retries on 5xx server errors and network failures (null status code). /// /// The HTTP status code, or null for network failures /// True if the request should be retried @@ -126,16 +137,6 @@ private static bool ShouldRetry(HttpStatusCode? statusCode) var code = (int)statusCode.Value; return code >= 500 && code < 600; } - - /// - /// Dispatch an event Asynchronously by creating a new task and calls the - /// Async version of DispatchEvent - /// This is a "Fire and Forget" option - /// - public void DispatchEvent(LogEvent logEvent) - { - Task.Run(() => DispatchEventAsync(logEvent)); - } } } #endif diff --git a/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs b/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs index d6d0f7d6..93c8b4f3 100644 --- a/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs +++ b/OptimizelySDK/Event/Dispatcher/WebRequestEventDispatcher35.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright 2017, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -18,6 +18,7 @@ using System; using System.IO; using System.Net; +using System.Text; using System.Threading; using OptimizelySDK.Logger; using OptimizelySDK.Utils; @@ -26,12 +27,12 @@ namespace OptimizelySDK.Event.Dispatcher { public class WebRequestClientEventDispatcher35 : IEventDispatcher { - public Logger.ILogger Logger { get; set; } + public ILogger Logger { get; set; } /// - /// Dispatch the Event with retry and exponential backoff. - /// The call will not wait for the result, it returns after sending (fire and forget) - /// But it does get called back asynchronously when the response comes and handles + /// Dispatch the Event with retry and exponential backoff. + /// The call will not wait for the result, it returns after sending (fire and forget) + /// But it does get called back asynchronously when the response comes and handles /// /// public void DispatchEvent(LogEvent logEvent) @@ -40,7 +41,7 @@ public void DispatchEvent(LogEvent logEvent) } /// - /// Dispatch event with retry logic and exponential backoff. + /// Dispatch event with retry logic and exponential backoff. /// private void DispatchWithRetry(LogEvent logEvent) { @@ -80,10 +81,12 @@ private void DispatchWithRetry(LogEvent logEvent) if (response.StatusCode == HttpStatusCode.OK) { using (var responseStream = response.GetResponseStream()) - using (var responseReader = new StreamReader(responseStream, System.Text.Encoding.UTF8)) + using (var responseReader = + new StreamReader(responseStream, Encoding.UTF8)) { responseReader.ReadToEnd(); } + // Success - exit the retry loop return; } @@ -122,8 +125,8 @@ private void DispatchWithRetry(LogEvent logEvent) } /// - /// Determines whether a request should be retried based on HTTP status code. - /// Retries on 5xx server errors and network failures (null status code). + /// Determines whether a request should be retried based on HTTP status code. + /// Retries on 5xx server errors and network failures (null status code). /// private static bool ShouldRetry(HttpStatusCode? statusCode) { @@ -139,7 +142,7 @@ private static bool ShouldRetry(HttpStatusCode? statusCode) } /// - /// Helper method to log messages safely when Logger might be null. + /// Helper method to log messages safely when Logger might be null. /// private void LogMessage(LogLevel level, string message) { diff --git a/OptimizelySDK/Event/EventRetryConfig.cs b/OptimizelySDK/Event/EventRetryConfig.cs index 7ee84dbb..78472a0d 100644 --- a/OptimizelySDK/Event/EventRetryConfig.cs +++ b/OptimizelySDK/Event/EventRetryConfig.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -17,31 +17,31 @@ namespace OptimizelySDK.Event { /// - /// Configuration constants for retrying event dispatch requests with exponential backoff. - /// Per ticket requirements: - /// - Max 3 total attempts (1 initial + 2 retries) - /// - Start at 200ms, exponentially grow to max 1 second + /// Configuration constants for retrying event dispatch requests with exponential backoff. + /// Per ticket requirements: + /// - Max 3 total attempts (1 initial + 2 retries) + /// - Start at 200ms, exponentially grow to max 1 second /// public static class EventRetryConfig { /// - /// Maximum number of retry attempts after the initial attempt fails. - /// Total attempts = 1 (initial) + MAX_RETRIES = 3 + /// Maximum number of retry attempts after the initial attempt fails. + /// Total attempts = 1 (initial) + MAX_RETRIES = 3 /// public const int MAX_RETRIES = 2; /// - /// Initial backoff delay in milliseconds before the first retry. + /// Initial backoff delay in milliseconds before the first retry. /// public const int INITIAL_BACKOFF_MS = 200; /// - /// Maximum backoff delay in milliseconds between retries. + /// Maximum backoff delay in milliseconds between retries. /// public const int MAX_BACKOFF_MS = 1000; /// - /// Multiplier applied to the backoff delay after each retry. + /// Multiplier applied to the backoff delay after each retry. /// public const double BACKOFF_MULTIPLIER = 2.0; } diff --git a/OptimizelySDK/Odp/OdpEventManager.cs b/OptimizelySDK/Odp/OdpEventManager.cs index 9f224b2b..b880daa9 100644 --- a/OptimizelySDK/Odp/OdpEventManager.cs +++ b/OptimizelySDK/Odp/OdpEventManager.cs @@ -249,17 +249,17 @@ private void FlushQueue() { shouldRetry = _odpEventApiManager.SendEvents(_odpConfig.ApiKey, _odpConfig.ApiHost, toProcessBatch); - + if (shouldRetry && attemptNumber < maxAttempts - 1) { Thread.Sleep(backoffMs); backoffMs = Math.Min(EventRetryConfig.MAX_BACKOFF_MS, (int)(backoffMs * EventRetryConfig.BACKOFF_MULTIPLIER)); } - + attemptNumber += 1; } while (shouldRetry && attemptNumber < maxAttempts); - + if (shouldRetry) { _logger.Log(LogLevel.ERROR, From ab3efea07b12da5a7cccb8ff221d10f24682001a Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 19 Jan 2026 23:56:08 +0600 Subject: [PATCH 3/5] [FSSDK-12148] class definition fix --- OptimizelySDK/Event/EventRetryConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OptimizelySDK/Event/EventRetryConfig.cs b/OptimizelySDK/Event/EventRetryConfig.cs index 78472a0d..4a1a9251 100644 --- a/OptimizelySDK/Event/EventRetryConfig.cs +++ b/OptimizelySDK/Event/EventRetryConfig.cs @@ -22,7 +22,7 @@ namespace OptimizelySDK.Event /// - Max 3 total attempts (1 initial + 2 retries) /// - Start at 200ms, exponentially grow to max 1 second /// - public static class EventRetryConfig + internal static class EventRetryConfig { /// /// Maximum number of retry attempts after the initial attempt fails. From cb6fe564f97fb9b6c98d5f2912e11b1f0512562b Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:40:39 +0600 Subject: [PATCH 4/5] [FSSDK-12148] test addition --- .../HttpClientEventDispatcher45Test.cs | 214 ++++++++++++++++++ .../OptimizelySDK.Tests.csproj | 1 + .../Dispatcher/HttpClientEventDispatcher45.cs | 25 +- 3 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs diff --git a/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs b/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs new file mode 100644 index 00000000..56293001 --- /dev/null +++ b/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs @@ -0,0 +1,214 @@ +/* + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if !NET35 && !NET40 +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using OptimizelySDK.Event; +using OptimizelySDK.Event.Dispatcher; +using OptimizelySDK.Logger; + +namespace OptimizelySDK.Tests.EventTests +{ + [TestFixture] + public class HttpClientEventDispatcher45Test + { + [SetUp] + public void Setup() + { + _mockLogger = new Mock(); + _mockLogger.Setup(l => l.Log(It.IsAny(), It.IsAny())); + _requestTimestamps = new List(); + } + + private Mock _mockLogger; + private List _requestTimestamps; + + [Test] + public void DispatchEvent_Success_SingleAttempt() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK); + var httpClient = new HttpClient(handler); + var dispatcher = new HttpClientEventDispatcher45(httpClient) + { + Logger = _mockLogger.Object, + }; + var logEvent = CreateLogEvent(); + + dispatcher.DispatchEvent(logEvent); + Thread.Sleep(500); // Wait for async dispatch + + Assert.AreEqual(1, handler.RequestCount); + _mockLogger.Verify(l => l.Log(LogLevel.ERROR, It.IsAny()), Times.Never); + } + + [Test] + public void DispatchEvent_ServerError500_RetriesThreeTimes() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError); + var httpClient = new HttpClient(handler); + var dispatcher = new HttpClientEventDispatcher45(httpClient) + { + Logger = _mockLogger.Object, + }; + var logEvent = CreateLogEvent(); + + dispatcher.DispatchEvent(logEvent); + Thread.Sleep(1500); + + Assert.AreEqual(3, handler.RequestCount); + _mockLogger.Verify( + l => l.Log(LogLevel.ERROR, It.Is(s => s.Contains("3 attempt(s)"))), + Times.Once); + } + + [Test] + public void DispatchEvent_ClientError400_NoRetry() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest); + var httpClient = new HttpClient(handler); + var dispatcher = new HttpClientEventDispatcher45(httpClient) + { + Logger = _mockLogger.Object, + }; + var logEvent = CreateLogEvent(); + + dispatcher.DispatchEvent(logEvent); + Thread.Sleep(500); + + Assert.AreEqual(1, handler.RequestCount); + _mockLogger.Verify(l => l.Log(LogLevel.ERROR, It.IsAny()), Times.Once); + } + + [Test] + public void DispatchEvent_SucceedsOnSecondAttempt_StopsRetrying() + { + var handler = new MockHttpMessageHandler(new[] + { + HttpStatusCode.InternalServerError, + HttpStatusCode.OK, + }); + var httpClient = new HttpClient(handler); + var dispatcher = new HttpClientEventDispatcher45(httpClient) + { + Logger = _mockLogger.Object, + }; + var logEvent = CreateLogEvent(); + + dispatcher.DispatchEvent(logEvent); + Thread.Sleep(1000); + + Assert.AreEqual(2, handler.RequestCount); + _mockLogger.Verify(l => l.Log(LogLevel.ERROR, It.IsAny()), Times.Never); + } + + [Test] + public void DispatchEvent_ExponentialBackoff_VerifyTiming() + { + var handler = + new MockHttpMessageHandler(HttpStatusCode.InternalServerError, _requestTimestamps); + var httpClient = new HttpClient(handler); + var dispatcher = new HttpClientEventDispatcher45(httpClient) + { + Logger = _mockLogger.Object, + }; + var logEvent = CreateLogEvent(); + + dispatcher.DispatchEvent(logEvent); + Thread.Sleep(1500); // Wait for all retries + + Assert.AreEqual(3, _requestTimestamps.Count); + + // First retry after ~200ms + var firstDelay = (_requestTimestamps[1] - _requestTimestamps[0]).TotalMilliseconds; + Assert.That(firstDelay, Is.GreaterThanOrEqualTo(180).And.LessThan(350), + $"First retry delay was {firstDelay}ms, expected ~200ms"); + + // Second retry after ~400ms + var secondDelay = (_requestTimestamps[2] - _requestTimestamps[1]).TotalMilliseconds; + Assert.That(secondDelay, Is.GreaterThanOrEqualTo(380).And.LessThan(550), + $"Second retry delay was {secondDelay}ms, expected ~400ms"); + } + + private static LogEvent CreateLogEvent() + { + return new LogEvent( + "https://logx.optimizely.com/v1/events", + new Dictionary + { + { "accountId", "12345" }, + { "visitors", new object[] { } }, + }, + "POST", + new Dictionary()); + } + + /// + /// Mock HTTP message handler for testing. + /// + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode[] _statusCodes; + private readonly List _timestamps; + private int _currentIndex; + + public MockHttpMessageHandler(HttpStatusCode statusCode, + List timestamps = null + ) + : this(new[] { statusCode }, timestamps) { } + + public MockHttpMessageHandler(HttpStatusCode[] statusCodes, + List timestamps = null + ) + { + _statusCodes = statusCodes; + _timestamps = timestamps; + _currentIndex = 0; + } + + public int RequestCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + RequestCount++; + _timestamps?.Add(DateTime.Now); + + var statusCode = _currentIndex < _statusCodes.Length ? + _statusCodes[_currentIndex] : + _statusCodes[_statusCodes.Length - 1]; + + _currentIndex++; + + var response = new HttpResponseMessage(statusCode) + { + Content = new StringContent("{}"), + }; + + return Task.FromResult(response); + } + } + } +} +#endif diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index c250d0e1..ab8ae0f2 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -101,6 +101,7 @@ + diff --git a/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs b/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs index 168297b7..1476cfa1 100644 --- a/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs +++ b/OptimizelySDK/Event/Dispatcher/HttpClientEventDispatcher45.cs @@ -28,16 +28,29 @@ namespace OptimizelySDK.Event.Dispatcher public class HttpClientEventDispatcher45 : IEventDispatcher { /// - /// HTTP client object. + /// Default shared HTTP client instance for all dispatchers. /// - private static readonly HttpClient Client; + private static readonly HttpClient DefaultClient = new HttpClient(); /// - /// Constructor for initializing static members. + /// HTTP client instance used by this dispatcher. /// - static HttpClientEventDispatcher45() + private readonly HttpClient _client; + + /// + /// Default constructor using the shared static HttpClient. + /// + public HttpClientEventDispatcher45() : this(null) + { + } + + /// + /// Constructor allowing injection of a custom HttpClient for testing. + /// + /// Custom HttpClient instance, or null to use the default shared instance. + internal HttpClientEventDispatcher45(HttpClient httpClient) { - Client = new HttpClient(); + _client = httpClient ?? DefaultClient; } public ILogger Logger { get; set; } = new DefaultLogger(); @@ -85,7 +98,7 @@ private async Task DispatchEventAsync(LogEvent logEvent) } } - response = await Client.SendAsync(request).ConfigureAwait(false); + response = await _client.SendAsync(request).ConfigureAwait(false); response.EnsureSuccessStatusCode(); // Success - exit the retry loop From ab38fe31610616f9a97ef802c1405d3c2e15dec2 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:44:32 +0600 Subject: [PATCH 5/5] [FSSDK-12148] format fix --- .../EventTests/HttpClientEventDispatcher45Test.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs b/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs index 56293001..da5bb314 100644 --- a/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs +++ b/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs @@ -42,7 +42,7 @@ public void Setup() private Mock _mockLogger; private List _requestTimestamps; - + [Test] public void DispatchEvent_Success_SingleAttempt() {