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.Tests/EventTests/HttpClientEventDispatcher45Test.cs b/OptimizelySDK.Tests/EventTests/HttpClientEventDispatcher45Test.cs
new file mode 100644
index 00000000..da5bb314
--- /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/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.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 dd7b9e0f..1476cfa1 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,7 +16,9 @@
#if !NET35 && !NET40
using System;
+using System.Net;
using System.Net.Http;
+using System.Text;
using System.Threading.Tasks;
using OptimizelySDK.Logger;
using OptimizelySDK.Utils;
@@ -25,63 +27,128 @@ namespace OptimizelySDK.Event.Dispatcher
{
public class HttpClientEventDispatcher45 : IEventDispatcher
{
- public ILogger Logger { get; set; } = new DefaultLogger();
+ ///
+ /// Default shared HTTP client instance for all dispatchers.
+ ///
+ private static readonly HttpClient DefaultClient = new HttpClient();
///
- /// HTTP client object.
+ /// HTTP client instance used by this dispatcher.
///
- private static readonly HttpClient Client;
+ private readonly HttpClient _client;
///
- /// Constructor for initializing static members.
+ /// Default constructor using the shared static HttpClient.
///
- static HttpClientEventDispatcher45()
+ public HttpClientEventDispatcher45() : this(null)
{
- Client = new HttpClient();
}
///
- /// Dispatch an Event asynchronously
+ /// Constructor allowing injection of a custom HttpClient for testing.
///
- private async void DispatchEventAsync(LogEvent logEvent)
+ /// Custom HttpClient instance, or null to use the default shared instance.
+ internal HttpClientEventDispatcher45(HttpClient httpClient)
{
- try
+ _client = httpClient ?? DefaultClient;
+ }
+
+ public ILogger Logger { get; set; } = new DefaultLogger();
+
+ ///
+ /// 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)
+ {
+ 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, 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();
- }
- catch (Exception ex)
- {
- Logger.Log(LogLevel.ERROR, $"Error Dispatching Event: {ex.GetAllMessages()}");
+ 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;
+ }
}
}
///
- /// Dispatch an event Asynchronously by creating a new task and calls the
- /// Async version of DispatchEvent
- /// This is a "Fire and Forget" option
+ /// Determines whether a request should be retried based on HTTP status code.
+ /// Retries on 5xx server errors and network failures (null status code).
///
- public void DispatchEvent(LogEvent logEvent)
+ /// The HTTP status code, or null for network failures
+ /// True if the request should be retried
+ private static bool ShouldRetry(HttpStatusCode? statusCode)
{
- Task.Run(() => DispatchEventAsync(logEvent));
+ // Retry on network failures (no response)
+ if (statusCode == null)
+ {
+ 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..93c8b4f3 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,135 @@
using System;
using System.IO;
using System.Net;
+using System.Text;
+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;
+ public ILogger Logger { get; set; }
///
- /// Dispatch the Event
- /// 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)
{
- 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, Encoding.UTF8))
+ {
+ responseReader.ReadToEnd();
+ }
- 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();
+ // Success - exit the retry loop
+ return;
+ }
+ }
+ catch (WebException ex)
+ {
+ var httpResponse = ex.Response as HttpWebResponse;
+ var shouldRetry = ShouldRetry(httpResponse?.StatusCode);
+
+ 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..4a1a9251
--- /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
+ ///
+ internal 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..b880daa9 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 @@
+