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;
}