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()
{