Skip to content

Commit 34c736c

Browse files
[FSSDK-11150] cmab client implementation (#392)
1 parent 8e90274 commit 34c736c

File tree

10 files changed

+607
-0
lines changed

10 files changed

+607
-0
lines changed

OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,21 @@
181181
<Compile Include="..\OptimizelySDK\Entity\Cmab.cs">
182182
<Link>Entity\Cmab.cs</Link>
183183
</Compile>
184+
<Compile Include="..\OptimizelySDK\Cmab\ICmabClient.cs">
185+
<Link>Cmab\ICmabClient.cs</Link>
186+
</Compile>
187+
<Compile Include="..\OptimizelySDK\Cmab\DefaultCmabClient.cs">
188+
<Link>Cmab\DefaultCmabClient.cs</Link>
189+
</Compile>
190+
<Compile Include="..\OptimizelySDK\Cmab\CmabRetryConfig.cs">
191+
<Link>Cmab\CmabRetryConfig.cs</Link>
192+
</Compile>
193+
<Compile Include="..\OptimizelySDK\Cmab\CmabModels.cs">
194+
<Link>Cmab\CmabModels.cs</Link>
195+
</Compile>
196+
<Compile Include="..\OptimizelySDK\Cmab\CmabConstants.cs">
197+
<Link>Cmab\CmabConstants.cs</Link>
198+
</Compile>
184199
<Compile Include="..\OptimizelySDK\Entity\Holdout.cs">
185200
<Link>Entity\Holdout.cs</Link>
186201
</Compile>
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/*
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
using System;
18+
using System.Collections.Generic;
19+
using System.Net;
20+
using System.Net.Http;
21+
using System.Threading;
22+
using System.Threading.Tasks;
23+
using Moq;
24+
using Moq.Protected;
25+
using NUnit.Framework;
26+
using OptimizelySDK.Cmab;
27+
using OptimizelySDK.ErrorHandler;
28+
using OptimizelySDK.Exceptions;
29+
using OptimizelySDK.Logger;
30+
31+
namespace OptimizelySDK.Tests.CmabTests
32+
{
33+
[TestFixture]
34+
public class DefaultCmabClientTest
35+
{
36+
private class ResponseStep
37+
{
38+
public HttpStatusCode Status { get; private set; }
39+
public string Body { get; private set; }
40+
public ResponseStep(HttpStatusCode status, string body)
41+
{
42+
Status = status;
43+
Body = body;
44+
}
45+
}
46+
47+
private static HttpClient MakeClient(params ResponseStep[] sequence)
48+
{
49+
var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
50+
var queue = new Queue<ResponseStep>(sequence);
51+
52+
handler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync",
53+
ItExpr.IsAny<HttpRequestMessage>(),
54+
ItExpr.IsAny<CancellationToken>()).Returns((HttpRequestMessage _, CancellationToken __) =>
55+
{
56+
if (queue.Count == 0)
57+
throw new InvalidOperationException("No more mocked responses available.");
58+
59+
var step = queue.Dequeue();
60+
var response = new HttpResponseMessage(step.Status);
61+
if (step.Body != null)
62+
{
63+
response.Content = new StringContent(step.Body);
64+
}
65+
return Task.FromResult(response);
66+
});
67+
68+
return new HttpClient(handler.Object);
69+
}
70+
71+
private static HttpClient MakeClientExceptionSequence(params Exception[] sequence)
72+
{
73+
var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
74+
var queue = new Queue<Exception>(sequence);
75+
76+
handler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync",
77+
ItExpr.IsAny<HttpRequestMessage>(),
78+
ItExpr.IsAny<CancellationToken>()).Returns((HttpRequestMessage _, CancellationToken __) =>
79+
{
80+
if (queue.Count == 0)
81+
throw new InvalidOperationException("No more mocked exceptions available.");
82+
83+
var ex = queue.Dequeue();
84+
var tcs = new TaskCompletionSource<HttpResponseMessage>();
85+
tcs.SetException(ex);
86+
return tcs.Task;
87+
});
88+
89+
return new HttpClient(handler.Object);
90+
}
91+
92+
private static string ValidBody(string variationId = "v1")
93+
=> $"{{\"predictions\":[{{\"variation_id\":\"{variationId}\"}}]}}";
94+
95+
[Test]
96+
public void FetchDecisionReturnsSuccessNoRetry()
97+
{
98+
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v1")));
99+
var client = new DefaultCmabClient(http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler());
100+
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
101+
102+
Assert.AreEqual("v1", result);
103+
}
104+
105+
[Test]
106+
public void FetchDecisionHttpExceptionNoRetry()
107+
{
108+
var http = MakeClientExceptionSequence(new HttpRequestException("boom"));
109+
var client = new DefaultCmabClient(http, retryConfig: null);
110+
111+
Assert.Throws<CmabFetchException>(() =>
112+
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
113+
}
114+
115+
[Test]
116+
public void FetchDecisionNon2xxNoRetry()
117+
{
118+
var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null));
119+
var client = new DefaultCmabClient(http, retryConfig: null);
120+
121+
Assert.Throws<CmabFetchException>(() =>
122+
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
123+
}
124+
125+
[Test]
126+
public void FetchDecisionInvalidJsonNoRetry()
127+
{
128+
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json"));
129+
var client = new DefaultCmabClient(http, retryConfig: null);
130+
131+
Assert.Throws<CmabInvalidResponseException>(() =>
132+
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
133+
}
134+
135+
[Test]
136+
public void FetchDecisionInvalidStructureNoRetry()
137+
{
138+
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}"));
139+
var client = new DefaultCmabClient(http, retryConfig: null);
140+
141+
Assert.Throws<CmabInvalidResponseException>(() =>
142+
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
143+
}
144+
145+
[Test]
146+
public void FetchDecisionSuccessWithRetryFirstTry()
147+
{
148+
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v2")));
149+
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
150+
var client = new DefaultCmabClient(http, retry);
151+
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
152+
153+
Assert.AreEqual("v2", result);
154+
}
155+
156+
[Test]
157+
public void FetchDecisionSuccessWithRetryThirdTry()
158+
{
159+
var http = MakeClient(
160+
new ResponseStep(HttpStatusCode.InternalServerError, null),
161+
new ResponseStep(HttpStatusCode.InternalServerError, null),
162+
new ResponseStep(HttpStatusCode.OK, ValidBody("v3"))
163+
);
164+
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
165+
var client = new DefaultCmabClient(http, retry);
166+
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
167+
168+
Assert.AreEqual("v3", result);
169+
}
170+
171+
[Test]
172+
public void FetchDecisionExhaustsAllRetries()
173+
{
174+
var http = MakeClient(
175+
new ResponseStep(HttpStatusCode.InternalServerError, null),
176+
new ResponseStep(HttpStatusCode.InternalServerError, null),
177+
new ResponseStep(HttpStatusCode.InternalServerError, null)
178+
);
179+
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
180+
var client = new DefaultCmabClient(http, retry);
181+
182+
Assert.Throws<CmabFetchException>(() =>
183+
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
184+
}
185+
}
186+
}

OptimizelySDK.Tests/OptimizelySDK.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
</ItemGroup>
7171
<ItemGroup>
7272
<Compile Include="Assertions.cs"/>
73+
<Compile Include="CmabTests\DefaultCmabClientTest.cs"/>
7374
<Compile Include="AudienceConditionsTests\ConditionEvaluationTest.cs"/>
7475
<Compile Include="AudienceConditionsTests\ConditionsTest.cs"/>
7576
<Compile Include="AudienceConditionsTests\SegmentsTests.cs"/>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
using System;
18+
19+
namespace OptimizelySDK.Cmab
20+
{
21+
internal static class CmabConstants
22+
{
23+
public const string PredictionUrl = "https://prediction.cmab.optimizely.com/predict";
24+
public static readonly TimeSpan MaxTimeout = TimeSpan.FromSeconds(10);
25+
26+
public const string ContentTypeJson = "application/json";
27+
28+
public const string ErrorFetchFailedFmt = "CMAB decision fetch failed with status: {0}";
29+
public const string ErrorInvalidResponse = "Invalid CMAB fetch response";
30+
public const string ExhaustRetryMessage = "Exhausted all retries for CMAB request";
31+
}
32+
}

OptimizelySDK/Cmab/CmabModels.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
using System.Collections.Generic;
18+
using Newtonsoft.Json;
19+
20+
namespace OptimizelySDK.Cmab
21+
{
22+
internal class CmabAttribute
23+
{
24+
[JsonProperty("id")] public string Id { get; set; }
25+
[JsonProperty("value")] public object Value { get; set; }
26+
[JsonProperty("type")] public string Type { get; set; } = "custom_attribute";
27+
}
28+
29+
internal class CmabInstance
30+
{
31+
[JsonProperty("visitorId")] public string VisitorId { get; set; }
32+
[JsonProperty("experimentId")] public string ExperimentId { get; set; }
33+
[JsonProperty("attributes")] public List<CmabAttribute> Attributes { get; set; }
34+
[JsonProperty("cmabUUID")] public string CmabUUID { get; set; }
35+
}
36+
37+
internal class CmabRequest
38+
{
39+
[JsonProperty("instances")] public List<CmabInstance> Instances { get; set; }
40+
}
41+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
using System;
18+
19+
namespace OptimizelySDK.Cmab
20+
{
21+
/// <summary>
22+
/// Configuration for retrying CMAB requests (exponential backoff).
23+
/// </summary>
24+
public class CmabRetryConfig
25+
{
26+
public int MaxRetries { get; }
27+
public TimeSpan InitialBackoff { get; }
28+
public TimeSpan MaxBackoff { get; }
29+
public double BackoffMultiplier { get; }
30+
31+
public CmabRetryConfig(
32+
int maxRetries = 3,
33+
TimeSpan? initialBackoff = null,
34+
TimeSpan? maxBackoff = null,
35+
double backoffMultiplier = 2.0)
36+
{
37+
MaxRetries = maxRetries;
38+
InitialBackoff = initialBackoff ?? TimeSpan.FromMilliseconds(100);
39+
MaxBackoff = maxBackoff ?? TimeSpan.FromSeconds(10);
40+
BackoffMultiplier = backoffMultiplier;
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)