Skip to content

Commit 298d515

Browse files
committed
add cmab client and tests
1 parent 081b394 commit 298d515

File tree

2 files changed

+876
-0
lines changed

2 files changed

+876
-0
lines changed

pkg/decision/cmab_client.go

+255
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
ruleID string,
131+
userID string,
132+
attributes map[string]interface{},
133+
cmabUUID string,
134+
) (string, error) {
135+
// Create the URL
136+
url := fmt.Sprintf(CMABPredictionEndpoint, ruleID)
137+
138+
// Convert attributes to CMAB format
139+
cmabAttributes := make([]CMABAttribute, 0, len(attributes))
140+
for key, value := range attributes {
141+
cmabAttributes = append(cmabAttributes, CMABAttribute{
142+
ID: key,
143+
Value: value,
144+
Type: "custom_attribute",
145+
})
146+
}
147+
148+
// Create the request body
149+
requestBody := CMABRequest{
150+
Instances: []CMABInstance{
151+
{
152+
VisitorID: userID,
153+
ExperimentID: ruleID,
154+
Attributes: cmabAttributes,
155+
CmabUUID: cmabUUID,
156+
},
157+
},
158+
}
159+
160+
// Serialize the request body
161+
bodyBytes, err := json.Marshal(requestBody)
162+
if err != nil {
163+
return "", fmt.Errorf("failed to marshal CMAB request: %w", err)
164+
}
165+
166+
// Create context for cancellation
167+
ctx := context.Background()
168+
169+
// If no retry config, just do a single fetch
170+
if c.retryConfig == nil {
171+
return c.doFetch(ctx, url, bodyBytes)
172+
}
173+
174+
// Retry sending request with exponential backoff
175+
for i := 0; i <= c.retryConfig.MaxRetries; i++ {
176+
// Make the request
177+
result, err := c.doFetch(ctx, url, bodyBytes)
178+
if err == nil {
179+
return result, nil
180+
}
181+
182+
// If this is the last retry, return the error
183+
if i == c.retryConfig.MaxRetries {
184+
return "", fmt.Errorf("failed to fetch CMAB decision after %d attempts: %w",
185+
c.retryConfig.MaxRetries, err)
186+
}
187+
188+
// Calculate backoff duration
189+
backoffDuration := c.retryConfig.InitialBackoff * time.Duration(math.Pow(c.retryConfig.BackoffMultiplier, float64(i)))
190+
if backoffDuration > c.retryConfig.MaxBackoff {
191+
backoffDuration = c.retryConfig.MaxBackoff
192+
}
193+
194+
c.logger.Debug(fmt.Sprintf("CMAB request retry %d/%d, backing off for %v",
195+
i+1, c.retryConfig.MaxRetries, backoffDuration))
196+
197+
// Wait for backoff duration
198+
time.Sleep(backoffDuration)
199+
200+
c.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v",
201+
i+1, c.retryConfig.MaxRetries, err))
202+
}
203+
204+
// This should never be reached due to the return in the loop above
205+
return "", fmt.Errorf("unexpected error in retry loop")
206+
}
207+
208+
// doFetch performs a single fetch operation to the CMAB API
209+
func (c *DefaultCmabClient) doFetch(ctx context.Context, url string, bodyBytes []byte) (string, error) {
210+
// Create the request
211+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
212+
if err != nil {
213+
return "", fmt.Errorf("failed to create CMAB request: %w", err)
214+
}
215+
216+
// Set headers
217+
req.Header.Set("Content-Type", "application/json")
218+
219+
// Execute the request
220+
resp, err := c.httpClient.Do(req)
221+
if err != nil {
222+
return "", fmt.Errorf("CMAB request failed: %w", err)
223+
}
224+
defer resp.Body.Close()
225+
226+
// Check status code
227+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
228+
return "", fmt.Errorf("CMAB API returned non-success status code: %d", resp.StatusCode)
229+
}
230+
231+
// Read response body
232+
respBody, err := io.ReadAll(resp.Body)
233+
if err != nil {
234+
return "", fmt.Errorf("failed to read CMAB response body: %w", err)
235+
}
236+
237+
// Parse response
238+
var cmabResponse CMABResponse
239+
if err := json.Unmarshal(respBody, &cmabResponse); err != nil {
240+
return "", fmt.Errorf("failed to unmarshal CMAB response: %w", err)
241+
}
242+
243+
// Validate response
244+
if !c.validateResponse(cmabResponse) {
245+
return "", fmt.Errorf("invalid CMAB response: missing predictions or variation_id")
246+
}
247+
248+
// Return the variation ID
249+
return cmabResponse.Predictions[0].VariationID, nil
250+
}
251+
252+
// validateResponse validates the CMAB response
253+
func (c *DefaultCmabClient) validateResponse(response CMABResponse) bool {
254+
return len(response.Predictions) > 0 && response.Predictions[0].VariationID != ""
255+
}

0 commit comments

Comments
 (0)