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 @@ +