Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@
<Compile Include="..\OptimizelySDK\Entity\Cmab.cs">
<Link>Entity\Cmab.cs</Link>
</Compile>
<Compile Include="..\OptimizelySDK\Cmab\ICmabClient.cs">
<Link>Cmab\ICmabClient.cs</Link>
</Compile>
<Compile Include="..\OptimizelySDK\Cmab\DefaultCmabClient.cs">
<Link>Cmab\DefaultCmabClient.cs</Link>
</Compile>
<Compile Include="..\OptimizelySDK\Cmab\CmabRetryConfig.cs">
<Link>Cmab\CmabRetryConfig.cs</Link>
</Compile>
<Compile Include="..\OptimizelySDK\Cmab\CmabModels.cs">
<Link>Cmab\CmabModels.cs</Link>
</Compile>
<Compile Include="..\OptimizelySDK\Cmab\CmabConstants.cs">
<Link>Cmab\CmabConstants.cs</Link>
</Compile>
<Compile Include="..\OptimizelySDK\Entity\Holdout.cs">
<Link>Entity\Holdout.cs</Link>
</Compile>
Expand Down
186 changes: 186 additions & 0 deletions OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs
Original file line number Diff line number Diff line change
@@ -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<HttpMessageHandler>(MockBehavior.Strict);
var queue = new Queue<ResponseStep>(sequence);

handler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()).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<HttpMessageHandler>(MockBehavior.Strict);
var queue = new Queue<Exception>(sequence);

handler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()).Returns((HttpRequestMessage _, CancellationToken __) =>
{
if (queue.Count == 0)
throw new InvalidOperationException("No more mocked exceptions available.");

var ex = queue.Dequeue();
var tcs = new TaskCompletionSource<HttpResponseMessage>();
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<CmabFetchException>(() =>
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<CmabFetchException>(() =>
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<CmabInvalidResponseException>(() =>
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<CmabInvalidResponseException>(() =>
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<CmabFetchException>(() =>
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
}
}
}
1 change: 1 addition & 0 deletions OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="Assertions.cs"/>
<Compile Include="CmabTests\DefaultCmabClientTest.cs"/>
<Compile Include="AudienceConditionsTests\ConditionEvaluationTest.cs"/>
<Compile Include="AudienceConditionsTests\ConditionsTest.cs"/>
<Compile Include="AudienceConditionsTests\SegmentsTests.cs"/>
Expand Down
32 changes: 32 additions & 0 deletions OptimizelySDK/Cmab/CmabConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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";
public const string ExhaustRetryMessage = "Exhausted all retries for CMAB request";
}
}
41 changes: 41 additions & 0 deletions OptimizelySDK/Cmab/CmabModels.cs
Original file line number Diff line number Diff line change
@@ -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<CmabAttribute> Attributes { get; set; }
[JsonProperty("cmabUUID")] public string CmabUUID { get; set; }
}

internal class CmabRequest
{
[JsonProperty("instances")] public List<CmabInstance> Instances { get; set; }
}
}
43 changes: 43 additions & 0 deletions OptimizelySDK/Cmab/CmabRetryConfig.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Configuration for retrying CMAB requests (exponential backoff).
/// </summary>
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;
}
}
}
Loading
Loading