Skip to content

Commit 656583c

Browse files
authored
[FSSDK-11142] Add cmab client and tests (#402)
* add cmab client and tests * improve tests
1 parent cc7161c commit 656583c

File tree

2 files changed

+958
-0
lines changed

2 files changed

+958
-0
lines changed

pkg/decision/cmab_client.go

+268
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
/****************************************************************************
2+
* Copyright 2025, Optimizely, Inc. and contributors *
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+
// Package decision provides CMAB client implementation
18+
package decision
19+
20+
import (
21+
"bytes"
22+
"context"
23+
"encoding/json"
24+
"fmt"
25+
"io"
26+
"math"
27+
"net/http"
28+
"time"
29+
30+
"github.com/optimizely/go-sdk/v2/pkg/logging"
31+
)
32+
33+
// CMABPredictionEndpoint is the endpoint for CMAB predictions
34+
var CMABPredictionEndpoint = "https://prediction.cmab.optimizely.com/predict/%s"
35+
36+
const (
37+
// DefaultMaxRetries is the default number of retries for CMAB requests
38+
DefaultMaxRetries = 3
39+
// DefaultInitialBackoff is the default initial backoff duration
40+
DefaultInitialBackoff = 100 * time.Millisecond
41+
// DefaultMaxBackoff is the default maximum backoff duration
42+
DefaultMaxBackoff = 10 * time.Second
43+
// DefaultBackoffMultiplier is the default multiplier for exponential backoff
44+
DefaultBackoffMultiplier = 2.0
45+
)
46+
47+
// CMABAttribute represents an attribute in a CMAB request
48+
type CMABAttribute struct {
49+
ID string `json:"id"`
50+
Value interface{} `json:"value"`
51+
Type string `json:"type"`
52+
}
53+
54+
// CMABInstance represents an instance in a CMAB request
55+
type CMABInstance struct {
56+
VisitorID string `json:"visitorId"`
57+
ExperimentID string `json:"experimentId"`
58+
Attributes []CMABAttribute `json:"attributes"`
59+
CmabUUID string `json:"cmabUUID"`
60+
}
61+
62+
// CMABRequest represents a request to the CMAB API
63+
type CMABRequest struct {
64+
Instances []CMABInstance `json:"instances"`
65+
}
66+
67+
// CMABPrediction represents a prediction in a CMAB response
68+
type CMABPrediction struct {
69+
VariationID string `json:"variation_id"`
70+
}
71+
72+
// CMABResponse represents a response from the CMAB API
73+
type CMABResponse struct {
74+
Predictions []CMABPrediction `json:"predictions"`
75+
}
76+
77+
// RetryConfig defines configuration for retry behavior
78+
type RetryConfig struct {
79+
// MaxRetries is the maximum number of retry attempts
80+
MaxRetries int
81+
// InitialBackoff is the initial backoff duration
82+
InitialBackoff time.Duration
83+
// MaxBackoff is the maximum backoff duration
84+
MaxBackoff time.Duration
85+
// BackoffMultiplier is the multiplier for exponential backoff
86+
BackoffMultiplier float64
87+
}
88+
89+
// DefaultCmabClient implements the CmabClient interface
90+
type DefaultCmabClient struct {
91+
httpClient *http.Client
92+
retryConfig *RetryConfig
93+
logger logging.OptimizelyLogProducer
94+
}
95+
96+
// CmabClientOptions defines options for creating a CMAB client
97+
type CmabClientOptions struct {
98+
HTTPClient *http.Client
99+
RetryConfig *RetryConfig
100+
Logger logging.OptimizelyLogProducer
101+
}
102+
103+
// NewDefaultCmabClient creates a new instance of DefaultCmabClient
104+
func NewDefaultCmabClient(options CmabClientOptions) *DefaultCmabClient {
105+
httpClient := options.HTTPClient
106+
if httpClient == nil {
107+
httpClient = &http.Client{
108+
Timeout: 10 * time.Second,
109+
}
110+
}
111+
112+
// retry is optional:
113+
// retryConfig can be nil - in that case, no retries will be performed
114+
retryConfig := options.RetryConfig
115+
116+
logger := options.Logger
117+
if logger == nil {
118+
logger = logging.GetLogger("", "DefaultCmabClient")
119+
}
120+
121+
return &DefaultCmabClient{
122+
httpClient: httpClient,
123+
retryConfig: retryConfig,
124+
logger: logger,
125+
}
126+
}
127+
128+
// FetchDecision fetches a decision from the CMAB API
129+
func (c *DefaultCmabClient) FetchDecision(
130+
ctx context.Context,
131+
ruleID string,
132+
userID string,
133+
attributes map[string]interface{},
134+
cmabUUID string,
135+
) (string, error) {
136+
// If no context is provided, create a background context
137+
if ctx == nil {
138+
ctx = context.Background()
139+
}
140+
141+
// Create the URL
142+
url := fmt.Sprintf(CMABPredictionEndpoint, ruleID)
143+
144+
// Convert attributes to CMAB format
145+
cmabAttributes := make([]CMABAttribute, 0, len(attributes))
146+
for key, value := range attributes {
147+
cmabAttributes = append(cmabAttributes, CMABAttribute{
148+
ID: key,
149+
Value: value,
150+
Type: "custom_attribute",
151+
})
152+
}
153+
154+
// Create the request body
155+
requestBody := CMABRequest{
156+
Instances: []CMABInstance{
157+
{
158+
VisitorID: userID,
159+
ExperimentID: ruleID,
160+
Attributes: cmabAttributes,
161+
CmabUUID: cmabUUID,
162+
},
163+
},
164+
}
165+
166+
// Serialize the request body
167+
bodyBytes, err := json.Marshal(requestBody)
168+
if err != nil {
169+
return "", fmt.Errorf("failed to marshal CMAB request: %w", err)
170+
}
171+
172+
// If no retry config, just do a single fetch
173+
if c.retryConfig == nil {
174+
return c.doFetch(ctx, url, bodyBytes)
175+
}
176+
177+
// Retry sending request with exponential backoff
178+
for i := 0; i <= c.retryConfig.MaxRetries; i++ {
179+
// Check if context is done
180+
if ctx.Err() != nil {
181+
return "", fmt.Errorf("context canceled or timed out: %w", ctx.Err())
182+
}
183+
184+
// Make the request
185+
result, err := c.doFetch(ctx, url, bodyBytes)
186+
if err == nil {
187+
return result, nil
188+
}
189+
190+
// If this is the last retry, return the error
191+
if i == c.retryConfig.MaxRetries {
192+
return "", fmt.Errorf("failed to fetch CMAB decision after %d attempts: %w",
193+
c.retryConfig.MaxRetries, err)
194+
}
195+
196+
// Calculate backoff duration
197+
backoffDuration := c.retryConfig.InitialBackoff * time.Duration(math.Pow(c.retryConfig.BackoffMultiplier, float64(i)))
198+
if backoffDuration > c.retryConfig.MaxBackoff {
199+
backoffDuration = c.retryConfig.MaxBackoff
200+
}
201+
202+
c.logger.Debug(fmt.Sprintf("CMAB request retry %d/%d, backing off for %v",
203+
i+1, c.retryConfig.MaxRetries, backoffDuration))
204+
205+
// Wait for backoff duration with context awareness
206+
select {
207+
case <-ctx.Done():
208+
return "", fmt.Errorf("context canceled or timed out during backoff: %w", ctx.Err())
209+
case <-time.After(backoffDuration):
210+
// Continue with retry
211+
}
212+
213+
c.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v",
214+
i+1, c.retryConfig.MaxRetries, err))
215+
}
216+
217+
// This should never be reached due to the return in the loop above
218+
return "", fmt.Errorf("unexpected error in retry loop")
219+
}
220+
221+
// doFetch performs a single fetch operation to the CMAB API
222+
func (c *DefaultCmabClient) doFetch(ctx context.Context, url string, bodyBytes []byte) (string, error) {
223+
// Create the request
224+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
225+
if err != nil {
226+
return "", fmt.Errorf("failed to create CMAB request: %w", err)
227+
}
228+
229+
// Set headers
230+
req.Header.Set("Content-Type", "application/json")
231+
232+
// Execute the request
233+
resp, err := c.httpClient.Do(req)
234+
if err != nil {
235+
return "", fmt.Errorf("CMAB request failed: %w", err)
236+
}
237+
defer resp.Body.Close()
238+
239+
// Check status code
240+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
241+
return "", fmt.Errorf("CMAB API returned non-success status code: %d", resp.StatusCode)
242+
}
243+
244+
// Read response body
245+
respBody, err := io.ReadAll(resp.Body)
246+
if err != nil {
247+
return "", fmt.Errorf("failed to read CMAB response body: %w", err)
248+
}
249+
250+
// Parse response
251+
var cmabResponse CMABResponse
252+
if err := json.Unmarshal(respBody, &cmabResponse); err != nil {
253+
return "", fmt.Errorf("failed to unmarshal CMAB response: %w", err)
254+
}
255+
256+
// Validate response
257+
if !c.validateResponse(cmabResponse) {
258+
return "", fmt.Errorf("invalid CMAB response: missing predictions or variation_id")
259+
}
260+
261+
// Return the variation ID
262+
return cmabResponse.Predictions[0].VariationID, nil
263+
}
264+
265+
// validateResponse validates the CMAB response
266+
func (c *DefaultCmabClient) validateResponse(response CMABResponse) bool {
267+
return len(response.Predictions) > 0 && response.Predictions[0].VariationID != ""
268+
}

0 commit comments

Comments
 (0)