From e4c006d8af9444bf5d7a8a044c77f03549383668 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 25 Sep 2025 01:02:27 +0600 Subject: [PATCH 1/4] [FSSDK-11150] cmab client implementation --- .../OptimizelySDK.NetStandard20.csproj | 15 ++ .../CmabTests/DefaultCmabClientTest.cs | 186 ++++++++++++++++ .../OptimizelySDK.Tests.csproj | 1 + OptimizelySDK/Cmab/CmabConstants.cs | 31 +++ OptimizelySDK/Cmab/CmabModels.cs | 41 ++++ OptimizelySDK/Cmab/CmabRetryConfig.cs | 43 ++++ OptimizelySDK/Cmab/DefaultCmabClient.cs | 208 ++++++++++++++++++ OptimizelySDK/Cmab/ICmabClient.cs | 41 ++++ .../Exceptions/OptimizelyException.cs | 27 +++ OptimizelySDK/OptimizelySDK.csproj | 5 + 10 files changed, 598 insertions(+) create mode 100644 OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs create mode 100644 OptimizelySDK/Cmab/CmabConstants.cs create mode 100644 OptimizelySDK/Cmab/CmabModels.cs create mode 100644 OptimizelySDK/Cmab/CmabRetryConfig.cs create mode 100644 OptimizelySDK/Cmab/DefaultCmabClient.cs create mode 100644 OptimizelySDK/Cmab/ICmabClient.cs diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index 418f606d..2ba52d48 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -181,6 +181,21 @@ Entity\Cmab.cs + + Cmab\ICmabClient.cs + + + Cmab\DefaultCmabClient.cs + + + Cmab\CmabRetryConfig.cs + + + Cmab\CmabModels.cs + + + Cmab\CmabConstants.cs + Entity\Holdout.cs diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs new file mode 100644 index 00000000..87a80e33 --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs @@ -0,0 +1,186 @@ +/* +* Copyright 2025, 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. +*/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; +using NUnit.Framework; +using OptimizelySDK.Cmab; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Exceptions; +using OptimizelySDK.Logger; + +namespace OptimizelySDK.Tests.CmabTests +{ + [TestFixture] + public class DefaultCmabClientTest + { + private class ResponseStep + { + public HttpStatusCode Status { get; private set; } + public string Body { get; private set; } + public ResponseStep(HttpStatusCode status, string body) + { + Status = status; + Body = body; + } + } + + private static HttpClient MakeClient(params ResponseStep[] sequence) + { + var handler = new Mock(MockBehavior.Strict); + var queue = new Queue(sequence); + + handler.Protected().Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()).Returns((HttpRequestMessage _, CancellationToken __) => + { + if (queue.Count == 0) + throw new InvalidOperationException("No more mocked responses available."); + + var step = queue.Dequeue(); + var response = new HttpResponseMessage(step.Status); + if (step.Body != null) + { + response.Content = new StringContent(step.Body); + } + return Task.FromResult(response); + }); + + return new HttpClient(handler.Object); + } + + private static HttpClient MakeClientExceptionSequence(params Exception[] sequence) + { + var handler = new Mock(MockBehavior.Strict); + var queue = new Queue(sequence); + + handler.Protected().Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()).Returns((HttpRequestMessage _, CancellationToken __) => + { + if (queue.Count == 0) + throw new InvalidOperationException("No more mocked exceptions available."); + + var ex = queue.Dequeue(); + var tcs = new TaskCompletionSource(); + tcs.SetException(ex); + return tcs.Task; + }); + + return new HttpClient(handler.Object); + } + + private static string ValidBody(string variationId = "v1") + => $"{{\"predictions\":[{{\"variation_id\":\"{variationId}\"}}]}}"; + + [Test] + public void FetchDecisionReturnsSuccessNoRetry() + { + var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v1"))); + var client = new DefaultCmabClient(http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler()); + var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); + + Assert.AreEqual("v1", result); + } + + [Test] + public void FetchDecisionHttpExceptionNoRetry() + { + var http = MakeClientExceptionSequence(new HttpRequestException("boom")); + var client = new DefaultCmabClient(http, retryConfig: null); + + Assert.Throws(() => + client.FetchDecision("rule-1", "user-1", null, "uuid-1")); + } + + [Test] + public void FetchDecisionNon2xxNoRetry() + { + var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null)); + var client = new DefaultCmabClient(http, retryConfig: null); + + Assert.Throws(() => + client.FetchDecision("rule-1", "user-1", null, "uuid-1")); + } + + [Test] + public void FetchDecisionInvalidJsonNoRetry() + { + var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json")); + var client = new DefaultCmabClient(http, retryConfig: null); + + Assert.Throws(() => + client.FetchDecision("rule-1", "user-1", null, "uuid-1")); + } + + [Test] + public void FetchDecisionInvalidStructureNoRetry() + { + var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}")); + var client = new DefaultCmabClient(http, retryConfig: null); + + Assert.Throws(() => + client.FetchDecision("rule-1", "user-1", null, "uuid-1")); + } + + [Test] + public void FetchDecisionSuccessWithRetryFirstTry() + { + var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v2"))); + var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0); + var client = new DefaultCmabClient(http, retry); + var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); + + Assert.AreEqual("v2", result); + } + + [Test] + public void FetchDecisionSuccessWithRetryThirdTry() + { + var http = MakeClient( + new ResponseStep(HttpStatusCode.InternalServerError, null), + new ResponseStep(HttpStatusCode.InternalServerError, null), + new ResponseStep(HttpStatusCode.OK, ValidBody("v3")) + ); + var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0); + var client = new DefaultCmabClient(http, retry); + var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); + + Assert.AreEqual("v3", result); + } + + [Test] + public void FetchDecisionExhaustsAllRetries() + { + var http = MakeClient( + new ResponseStep(HttpStatusCode.InternalServerError, null), + new ResponseStep(HttpStatusCode.InternalServerError, null), + new ResponseStep(HttpStatusCode.InternalServerError, null) + ); + var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0); + var client = new DefaultCmabClient(http, retry); + + Assert.Throws(() => + client.FetchDecision("rule-1", "user-1", null, "uuid-1")); + } + } +} diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index 026dd5b8..01469f77 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -70,6 +70,7 @@ + diff --git a/OptimizelySDK/Cmab/CmabConstants.cs b/OptimizelySDK/Cmab/CmabConstants.cs new file mode 100644 index 00000000..0c20e24b --- /dev/null +++ b/OptimizelySDK/Cmab/CmabConstants.cs @@ -0,0 +1,31 @@ +/* +* Copyright 2025, 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. +*/ + +using System; + +namespace OptimizelySDK.Cmab +{ + internal static class CmabConstants + { + public const string PredictionUrl = "https://prediction.cmab.optimizely.com/predict"; + public static readonly TimeSpan MaxTimeout = TimeSpan.FromSeconds(10); + + public const string ContentTypeJson = "application/json"; + + public const string ErrorFetchFailedFmt = "CMAB decision fetch failed with status: {0}"; + public const string ErrorInvalidResponse = "Invalid CMAB fetch response"; + } +} diff --git a/OptimizelySDK/Cmab/CmabModels.cs b/OptimizelySDK/Cmab/CmabModels.cs new file mode 100644 index 00000000..3a992458 --- /dev/null +++ b/OptimizelySDK/Cmab/CmabModels.cs @@ -0,0 +1,41 @@ +/* +* Copyright 2025, 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. +*/ + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace OptimizelySDK.Cmab +{ + internal class CmabAttribute + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("value")] public object Value { get; set; } + [JsonProperty("type")] public string Type { get; set; } = "custom_attribute"; + } + + internal class CmabInstance + { + [JsonProperty("visitorId")] public string VisitorId { get; set; } + [JsonProperty("experimentId")] public string ExperimentId { get; set; } + [JsonProperty("attributes")] public List Attributes { get; set; } + [JsonProperty("cmabUUID")] public string CmabUUID { get; set; } + } + + internal class CmabRequest + { + [JsonProperty("instances")] public List Instances { get; set; } + } +} diff --git a/OptimizelySDK/Cmab/CmabRetryConfig.cs b/OptimizelySDK/Cmab/CmabRetryConfig.cs new file mode 100644 index 00000000..d89d78c6 --- /dev/null +++ b/OptimizelySDK/Cmab/CmabRetryConfig.cs @@ -0,0 +1,43 @@ +/* +* Copyright 2025, 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. +*/ + +using System; + +namespace OptimizelySDK.Cmab +{ + /// + /// Configuration for retrying CMAB requests (exponential backoff). + /// + public class CmabRetryConfig + { + public int MaxRetries { get; } + public TimeSpan InitialBackoff { get; } + public TimeSpan MaxBackoff { get; } + public double BackoffMultiplier { get; } + + public CmabRetryConfig( + int maxRetries = 3, + TimeSpan? initialBackoff = null, + TimeSpan? maxBackoff = null, + double backoffMultiplier = 2.0) + { + MaxRetries = maxRetries; + InitialBackoff = initialBackoff ?? TimeSpan.FromMilliseconds(100); + MaxBackoff = maxBackoff ?? TimeSpan.FromSeconds(10); + BackoffMultiplier = backoffMultiplier; + } + } +} diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs new file mode 100644 index 00000000..df798b47 --- /dev/null +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -0,0 +1,208 @@ +/* +* Copyright 2025, 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. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Exceptions; +using OptimizelySDK.Logger; + +namespace OptimizelySDK.Cmab +{ + /// + /// Default client for interacting with the CMAB service via HttpClient. + /// + public class DefaultCmabClient : ICmabClient + { + private readonly HttpClient _httpClient; + private readonly CmabRetryConfig _retryConfig; + private readonly ILogger _logger; + private readonly IErrorHandler _errorHandler; + + public DefaultCmabClient( + HttpClient httpClient = null, + CmabRetryConfig retryConfig = null, + ILogger logger = null, + IErrorHandler errorHandler = null) + { + _httpClient = httpClient ?? new HttpClient(); + _retryConfig = retryConfig; + _logger = logger ?? new NoOpLogger(); + _errorHandler = errorHandler ?? new NoOpErrorHandler(); + } + + private async Task FetchDecisionAsync( + string ruleId, + string userId, + IDictionary attributes, + string cmabUuid, + TimeSpan? timeout = null) + { + var url = $"{CmabConstants.PredictionUrl}/{ruleId}"; + var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid); + var perAttemptTimeout = timeout ?? CmabConstants.MaxTimeout; + + if (_retryConfig == null) + { + return await DoFetchOnceAsync(url, body, perAttemptTimeout).ConfigureAwait(false); + } + return await DoFetchWithRetryAsync(url, body, perAttemptTimeout).ConfigureAwait(false); + } + + public string FetchDecision( + string ruleId, + string userId, + IDictionary attributes, + string cmabUuid, + TimeSpan? timeout = null) + { + try + { + return FetchDecisionAsync(ruleId, userId, attributes, cmabUuid, timeout).ConfigureAwait(false).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _errorHandler.HandleError(ex); + throw; + } + } + + private static StringContent BuildContent(object payload) + { + var json = JsonConvert.SerializeObject(payload); + return new StringContent(json, Encoding.UTF8, CmabConstants.ContentTypeJson); + } + + private static CmabRequest BuildRequestBody(string ruleId, string userId, IDictionary attributes, string cmabUuid) + { + var attrList = (attributes ?? new Dictionary()).Select(kv => new CmabAttribute { Id = kv.Key, Value = kv.Value }).ToList(); + + return new CmabRequest + { + Instances = new List + { + new CmabInstance + { + VisitorId = userId, + ExperimentId = ruleId, + Attributes = attrList, + CmabUUID = cmabUuid, + } + } + }; + } + + private async Task DoFetchOnceAsync(string url, CmabRequest request, TimeSpan timeout) + { + using (var cts = new CancellationTokenSource(timeout)) + { + + try + { + var httpRequest = new HttpRequestMessage + { + RequestUri = new Uri(url), + Method = HttpMethod.Post, + Content = BuildContent(request), + }; + + var response = await _httpClient.SendAsync(httpRequest, cts.Token).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var status = (int)response.StatusCode; + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, status)); + throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, status)); + } + + var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + var j = JObject.Parse(responseText); + if (!ValidateResponse(j)) + { + _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); + throw new CmabInvalidResponseException(CmabConstants.ErrorInvalidResponse); + } + + var variationIdToken = j["predictions"][0]["variation_id"]; + return variationIdToken?.ToString(); + } + catch (JsonException ex) + { + _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); + throw new CmabInvalidResponseException(ex.Message); + } + catch (HttpRequestException ex) + { + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + } + catch (Exception ex) when (!(ex is CmabInvalidResponseException)) + { + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + } + } + } + + private async Task DoFetchWithRetryAsync(string url, CmabRequest request, TimeSpan timeout) + { + var backoff = _retryConfig.InitialBackoff; + var attempt = 0; + while (true) + { + try + { + return await DoFetchOnceAsync(url, request, timeout).ConfigureAwait(false); + } + catch (Exception) + { + if (attempt >= _retryConfig.MaxRetries) + { + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, "Exhausted all retries for CMAB request.")); + throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, "Exhausted all retries for CMAB request.")); + } + + _logger.Log(LogLevel.INFO, $"Retrying CMAB request (attempt: {attempt + 1}) after {backoff.TotalSeconds} seconds..."); + await Task.Delay(backoff).ConfigureAwait(false); + var nextMs = Math.Min(_retryConfig.MaxBackoff.TotalMilliseconds, backoff.TotalMilliseconds * _retryConfig.BackoffMultiplier); + backoff = TimeSpan.FromMilliseconds(nextMs); + attempt++; + } + } + } + + private static bool ValidateResponse(JObject body) + { + if (body == null) return false; + + var preds = body["predictions"] as JArray; + if (preds == null || preds.Count == 0) return false; + + var first = preds[0] as JObject; + if (first == null) return false; + + return first["variation_id"] != null; + } + } +} diff --git a/OptimizelySDK/Cmab/ICmabClient.cs b/OptimizelySDK/Cmab/ICmabClient.cs new file mode 100644 index 00000000..d80aec61 --- /dev/null +++ b/OptimizelySDK/Cmab/ICmabClient.cs @@ -0,0 +1,41 @@ +/* +* Copyright 2025, 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. +*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OptimizelySDK.Cmab +{ + /// + /// Interface for CMAB client that fetches decisions from the prediction service. + /// + public interface ICmabClient + { + /// + /// Fetch a decision (variation id) from CMAB prediction service. + /// Throws on failure (network/non-2xx/invalid response/exhausted retries). + /// + /// Variation ID as string. + string FetchDecision( + string ruleId, + string userId, + IDictionary attributes, + string cmabUuid, + TimeSpan? timeout = null); + } +} diff --git a/OptimizelySDK/Exceptions/OptimizelyException.cs b/OptimizelySDK/Exceptions/OptimizelyException.cs index ba150b2d..2d6ec0d8 100644 --- a/OptimizelySDK/Exceptions/OptimizelyException.cs +++ b/OptimizelySDK/Exceptions/OptimizelyException.cs @@ -85,6 +85,33 @@ public InvalidFeatureException(string message) : base(message) { } } + /// + /// Base exception for CMAB client errors. + /// + public class CmabException : OptimizelyException + { + public CmabException(string message) + : base(message) { } + } + + /// + /// Exception thrown when CMAB decision fetch fails (network/non-2xx/exhausted retries). + /// + public class CmabFetchException : CmabException + { + public CmabFetchException(string message) + : base(message) { } + } + + /// + /// Exception thrown when CMAB response is invalid or cannot be parsed. + /// + public class CmabInvalidResponseException : CmabException + { + public CmabInvalidResponseException(string message) + : base(message) { } + } + public class InvalidRolloutException : OptimizelyException { public InvalidRolloutException(string message) diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index b4537b92..ccd53f42 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -204,6 +204,11 @@ + + + + + From 6d4c1c02839531436f393f6124806199c2ac6c4c Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 25 Sep 2025 01:12:15 +0600 Subject: [PATCH 2/4] [FSSDK-11150] review update --- OptimizelySDK/Cmab/CmabConstants.cs | 1 + OptimizelySDK/Cmab/DefaultCmabClient.cs | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OptimizelySDK/Cmab/CmabConstants.cs b/OptimizelySDK/Cmab/CmabConstants.cs index 0c20e24b..8c3659a1 100644 --- a/OptimizelySDK/Cmab/CmabConstants.cs +++ b/OptimizelySDK/Cmab/CmabConstants.cs @@ -27,5 +27,6 @@ internal static class CmabConstants public const string ErrorFetchFailedFmt = "CMAB decision fetch failed with status: {0}"; public const string ErrorInvalidResponse = "Invalid CMAB fetch response"; + public const string ExhaustRetryMessage = "Exhausted all retries for CMAB request"; } } diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs index df798b47..f2e0703f 100644 --- a/OptimizelySDK/Cmab/DefaultCmabClient.cs +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -116,7 +116,6 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim { using (var cts = new CancellationTokenSource(timeout)) { - try { var httpRequest = new HttpRequestMessage @@ -179,8 +178,8 @@ private async Task DoFetchWithRetryAsync(string url, CmabRequest request { if (attempt >= _retryConfig.MaxRetries) { - _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, "Exhausted all retries for CMAB request.")); - throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, "Exhausted all retries for CMAB request.")); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, CmabConstants.ExhaustRetryMessage)); + throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, CmabConstants.ExhaustRetryMessage)); } _logger.Log(LogLevel.INFO, $"Retrying CMAB request (attempt: {attempt + 1}) after {backoff.TotalSeconds} seconds..."); From 5227e6d31518348d453725913093ded2e0e581dd Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 25 Sep 2025 01:13:42 +0600 Subject: [PATCH 3/4] [FSSDK-11150] review update 2 --- OptimizelySDK/Cmab/DefaultCmabClient.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs index f2e0703f..f0c77925 100644 --- a/OptimizelySDK/Cmab/DefaultCmabClient.cs +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -151,12 +151,16 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); throw new CmabInvalidResponseException(ex.Message); } + catch(CmabInvalidResponseException) + { + throw; + } catch (HttpRequestException ex) { _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); } - catch (Exception ex) when (!(ex is CmabInvalidResponseException)) + catch (Exception ex) { _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); From d0486986162504b4adda88c48b05a821c42d279e Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:20:14 +0600 Subject: [PATCH 4/4] [FSSDK-11150] review update 3 --- OptimizelySDK/Cmab/DefaultCmabClient.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs index f0c77925..3faaec75 100644 --- a/OptimizelySDK/Cmab/DefaultCmabClient.cs +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -95,7 +95,12 @@ private static StringContent BuildContent(object payload) private static CmabRequest BuildRequestBody(string ruleId, string userId, IDictionary attributes, string cmabUuid) { - var attrList = (attributes ?? new Dictionary()).Select(kv => new CmabAttribute { Id = kv.Key, Value = kv.Value }).ToList(); + var attrList = new List(); + + if (attributes != null) + { + attrList = attributes.Select(kv => new CmabAttribute { Id = kv.Key, Value = kv.Value }).ToList(); + } return new CmabRequest { @@ -151,7 +156,7 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); throw new CmabInvalidResponseException(ex.Message); } - catch(CmabInvalidResponseException) + catch (CmabInvalidResponseException) { throw; }