• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

optimizely / go-sdk / 14856806193

06 May 2025 09:57AM UTC coverage: 91.634%. First build
14856806193

Pull #402

github

web-flow
Merge branch 'master' into mpirnovar-cmab-client-fssdk-11142
Pull Request #402: [FSSDK-11142] Add cmab client and tests

100 of 115 new or added lines in 1 file covered. (86.96%)

5071 of 5534 relevant lines covered (91.63%)

9876.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.96
/pkg/decision/cmab_client.go
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 {
19✔
105
        httpClient := options.HTTPClient
19✔
106
        if httpClient == nil {
20✔
107
                httpClient = &http.Client{
1✔
108
                        Timeout: 10 * time.Second,
1✔
109
                }
1✔
110
        }
1✔
111

112
        // retry is optional:
113
        // retryConfig can be nil - in that case, no retries will be performed
114
        retryConfig := options.RetryConfig
19✔
115

19✔
116
        logger := options.Logger
19✔
117
        if logger == nil {
36✔
118
                logger = logging.GetLogger("", "DefaultCmabClient")
17✔
119
        }
17✔
120

121
        return &DefaultCmabClient{
19✔
122
                httpClient:  httpClient,
19✔
123
                retryConfig: retryConfig,
19✔
124
                logger:      logger,
19✔
125
        }
19✔
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) {
18✔
136
        // If no context is provided, create a background context
18✔
137
        if ctx == nil {
18✔
NEW
138
                ctx = context.Background()
×
NEW
139
        }
×
140

141
        // Create the URL
142
        url := fmt.Sprintf(CMABPredictionEndpoint, ruleID)
18✔
143

18✔
144
        // Convert attributes to CMAB format
18✔
145
        cmabAttributes := make([]CMABAttribute, 0, len(attributes))
18✔
146
        for key, value := range attributes {
42✔
147
                cmabAttributes = append(cmabAttributes, CMABAttribute{
24✔
148
                        ID:    key,
24✔
149
                        Value: value,
24✔
150
                        Type:  "custom_attribute",
24✔
151
                })
24✔
152
        }
24✔
153

154
        // Create the request body
155
        requestBody := CMABRequest{
18✔
156
                Instances: []CMABInstance{
18✔
157
                        {
18✔
158
                                VisitorID:    userID,
18✔
159
                                ExperimentID: ruleID,
18✔
160
                                Attributes:   cmabAttributes,
18✔
161
                                CmabUUID:     cmabUUID,
18✔
162
                        },
18✔
163
                },
18✔
164
        }
18✔
165

18✔
166
        // Serialize the request body
18✔
167
        bodyBytes, err := json.Marshal(requestBody)
18✔
168
        if err != nil {
18✔
NEW
169
                return "", fmt.Errorf("failed to marshal CMAB request: %w", err)
×
NEW
170
        }
×
171

172
        // If no retry config, just do a single fetch
173
        if c.retryConfig == nil {
31✔
174
                return c.doFetch(ctx, url, bodyBytes)
13✔
175
        }
13✔
176

177
        // Retry sending request with exponential backoff
178
        for i := 0; i <= c.retryConfig.MaxRetries; i++ {
19✔
179
                // Check if context is done
14✔
180
                if ctx.Err() != nil {
14✔
NEW
181
                        return "", fmt.Errorf("context canceled or timed out: %w", ctx.Err())
×
NEW
182
                }
×
183

184
                // Make the request
185
                result, err := c.doFetch(ctx, url, bodyBytes)
14✔
186
                if err == nil {
17✔
187
                        return result, nil
3✔
188
                }
3✔
189

190
                // If this is the last retry, return the error
191
                if i == c.retryConfig.MaxRetries {
13✔
192
                        return "", fmt.Errorf("failed to fetch CMAB decision after %d attempts: %w",
2✔
193
                                c.retryConfig.MaxRetries, err)
2✔
194
                }
2✔
195

196
                // Calculate backoff duration
197
                backoffDuration := c.retryConfig.InitialBackoff * time.Duration(math.Pow(c.retryConfig.BackoffMultiplier, float64(i)))
9✔
198
                if backoffDuration > c.retryConfig.MaxBackoff {
9✔
NEW
199
                        backoffDuration = c.retryConfig.MaxBackoff
×
NEW
200
                }
×
201

202
                c.logger.Debug(fmt.Sprintf("CMAB request retry %d/%d, backing off for %v",
9✔
203
                        i+1, c.retryConfig.MaxRetries, backoffDuration))
9✔
204

9✔
205
                // Wait for backoff duration with context awareness
9✔
206
                select {
9✔
NEW
207
                case <-ctx.Done():
×
NEW
208
                        return "", fmt.Errorf("context canceled or timed out during backoff: %w", ctx.Err())
×
209
                case <-time.After(backoffDuration):
9✔
210
                        // Continue with retry
211
                }
212

213
                c.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v",
9✔
214
                        i+1, c.retryConfig.MaxRetries, err))
9✔
215
        }
216

217
        // This should never be reached due to the return in the loop above
NEW
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) {
27✔
223
        // Create the request
27✔
224
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
27✔
225
        if err != nil {
27✔
NEW
226
                return "", fmt.Errorf("failed to create CMAB request: %w", err)
×
NEW
227
        }
×
228

229
        // Set headers
230
        req.Header.Set("Content-Type", "application/json")
27✔
231

27✔
232
        // Execute the request
27✔
233
        resp, err := c.httpClient.Do(req)
27✔
234
        if err != nil {
30✔
235
                return "", fmt.Errorf("CMAB request failed: %w", err)
3✔
236
        }
3✔
237
        defer resp.Body.Close()
24✔
238

24✔
239
        // Check status code
24✔
240
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
40✔
241
                return "", fmt.Errorf("CMAB API returned non-success status code: %d", resp.StatusCode)
16✔
242
        }
16✔
243

244
        // Read response body
245
        respBody, err := io.ReadAll(resp.Body)
8✔
246
        if err != nil {
8✔
NEW
247
                return "", fmt.Errorf("failed to read CMAB response body: %w", err)
×
NEW
248
        }
×
249

250
        // Parse response
251
        var cmabResponse CMABResponse
8✔
252
        if err := json.Unmarshal(respBody, &cmabResponse); err != nil {
9✔
253
                return "", fmt.Errorf("failed to unmarshal CMAB response: %w", err)
1✔
254
        }
1✔
255

256
        // Validate response
257
        if !c.validateResponse(cmabResponse) {
10✔
258
                return "", fmt.Errorf("invalid CMAB response: missing predictions or variation_id")
3✔
259
        }
3✔
260

261
        // Return the variation ID
262
        return cmabResponse.Predictions[0].VariationID, nil
4✔
263
}
264

265
// validateResponse validates the CMAB response
266
func (c *DefaultCmabClient) validateResponse(response CMABResponse) bool {
7✔
267
        return len(response.Predictions) > 0 && response.Predictions[0].VariationID != ""
7✔
268
}
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc