Skip to content

Commit 9327b98

Browse files
committed
[FSSDK-11149] Ruby: Implement CMAB Client
1 parent 61a95c3 commit 9327b98

File tree

4 files changed

+452
-0
lines changed

4 files changed

+452
-0
lines changed

lib/optimizely/cmab/cmab_client.rb

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# frozen_string_literal: true
2+
3+
#
4+
# Copyright 2025 Optimizely and contributors
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
require 'optimizely/helpers/http_utils'
19+
require 'optimizely/helpers/constants'
20+
21+
module Optimizely
22+
# Default constants for CMAB requests
23+
DEFAULT_MAX_RETRIES = 3
24+
DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
25+
DEFAULT_MAX_BACKOFF = 10 # in seconds
26+
DEFAULT_BACKOFF_MULTIPLIER = 2.0
27+
MAX_WAIT_TIME = 10
28+
29+
class CmabRetryConfig
30+
# Configuration for retrying CMAB requests.
31+
# Contains parameters for maximum retries, backoff intervals, and multipliers.
32+
attr_reader :max_retries, :initial_backoff, :max_backoff, :backoff_multiplier
33+
34+
def initialize(max_retries: DEFAULT_MAX_RETRIES, initial_backoff: DEFAULT_INITIAL_BACKOFF, max_backoff: DEFAULT_BACKOFF_MULTIPLIER, backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER)
35+
@max_retries = max_retries
36+
@initial_backoff = initial_backoff
37+
@max_backoff = max_backoff
38+
@backoff_multiplier = backoff_multiplier
39+
end
40+
end
41+
42+
class DefaultCmabClient
43+
# Client for interacting with the CMAB service.
44+
# Provides methods to fetch decisions with optional retry logic.
45+
46+
def initialize(http_client = nil, retry_config = nil, logger = nil)
47+
# Initialize the CMAB client.
48+
# Args:
49+
# http_client: HTTP client for making requests.
50+
# retry_config: Configuration for retry settings.
51+
# logger: Logger for logging errors and info.
52+
@http_client = http_client || DefaultHttpClient.new
53+
@retry_config = retry_config || CmabRetryConfig.new
54+
@logger = logger || NoOpLogger.new
55+
end
56+
57+
def fetch_decision(rule_id, user_id, attributes, cmab_uuid, timeout: MAX_WAIT_TIME)
58+
# Fetches a decision from the CMAB service.
59+
# Args:
60+
# rule_id: The rule ID for the experiment.
61+
# user_id: The user ID for the request.
62+
# attributes: User attributes for the request.
63+
# cmab_uuid: Unique identifier for the CMAB request.
64+
# timeout: Maximum wait time for the request to respond in seconds. (default is 10 seconds).
65+
# Returns:
66+
# The variation ID.
67+
url = "https://prediction.cmab.optimizely.com/predict/#{rule_id}"
68+
cmab_attributes = attributes.map { |key, value| {'id' => key.to_s, 'value' => value, 'type' => 'custom_attribute'} }
69+
70+
request_body = {
71+
instances: [{
72+
visitorId: user_id,
73+
experimentId: rule_id,
74+
attributes: cmab_attributes,
75+
cmabUUID: cmab_uuid
76+
}]
77+
}
78+
79+
if @retry_config && @retry_config.max_retries.to_i.positive?
80+
_do_fetch_with_retry(url, request_body, @retry_config, timeout)
81+
else
82+
_do_fetch(url, request_body, timeout)
83+
end
84+
end
85+
86+
def _do_fetch(url, request_body, timeout)
87+
# Perform a single fetch request to the CMAB prediction service.
88+
89+
# Args:
90+
# url: The endpoint URL.
91+
# request_body: The request payload.
92+
# timeout: Maximum wait time for the request to respond in seconds.
93+
# Returns:
94+
# The variation ID from the response.
95+
96+
headers = {'Content-Type' => 'application/json'}
97+
begin
98+
response = @http_client.post(url, json: request_body, headers: headers, timeout: timeout.to_i)
99+
rescue StandardError => e
100+
error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % e.message
101+
@logger.log(Logger::ERROR, error_message)
102+
raise CmabFetchError, error_message
103+
end
104+
105+
unless (200..299).include?(response.status_code)
106+
error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % response.status_code
107+
@logger.log(Logger::ERROR, error_message)
108+
raise CmabFetchError, error_message
109+
end
110+
111+
begin
112+
body = response.json
113+
rescue JSON::ParserError, Optimizely::CmabInvalidResponseError
114+
error_message = Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE
115+
@logger.log(Logger::ERROR, error_message)
116+
raise CmabInvalidResponseError, error_message
117+
end
118+
119+
unless validate_response(body)
120+
error_message = Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE
121+
@logger.log(Logger::ERROR, error_message)
122+
raise CmabInvalidResponseError, error_message
123+
end
124+
125+
body['predictions'][0]['variationId']
126+
end
127+
128+
def validate_response(body)
129+
# Validate the response structure from the CMAB service.
130+
# Args:
131+
# body: The JSON response body to validate.
132+
# Returns:
133+
# true if valid, false otherwise.
134+
135+
body.is_a?(Hash) &&
136+
body.key?('predictions') &&
137+
body['predictions'].is_a?(Array) &&
138+
!body['predictions'].empty? &&
139+
body['predictions'][0].is_a?(Hash) &&
140+
body['predictions'][0].key?('variationId')
141+
end
142+
143+
def _do_fetch_with_retry(url, request_body, retry_config, timeout)
144+
# Perform a fetch request with retry logic.
145+
# Args:
146+
# url: The endpoint URL.
147+
# request_body: The request payload.
148+
# retry_config: Configuration for retry settings.
149+
# timeout: Maximum wait time for the request to respond in seconds.
150+
# Returns:
151+
# The variation ID from the response.
152+
153+
backoff = retry_config.initial_backoff
154+
155+
(0..retry_config.max_retries).each do |attempt|
156+
variation_id = _do_fetch(url, request_body, timeout)
157+
return variation_id
158+
rescue StandardError => e
159+
if attempt < retry_config.max_retries
160+
@logger.log(Logger::INFO, "Retrying CMAB request (attempt #{attempt + 1}) after #{backoff} seconds...")
161+
Kernel.sleep(backoff)
162+
163+
backoff = [
164+
backoff * (retry_config.backoff_multiplier**(attempt + 1)),
165+
retry_config.max_backoff
166+
].min
167+
else
168+
@logger.log(Logger::ERROR, "Max retries exceeded for CMAB request: #{e.message}")
169+
raise Optimizely::CmabFetchError, "CMAB decision fetch failed (#{e.message})."
170+
end
171+
end
172+
173+
error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % 'Exhausted all retries for CMAB request.'
174+
@logger.log(Logger::ERROR, error_message)
175+
raise Optimizely::CmabFetchError, error_message
176+
end
177+
end
178+
179+
class DefaultHttpClient
180+
# Default HTTP client for making requests.
181+
# Uses Optimizely::Helpers::HttpUtils to make requests.
182+
183+
def post(url, json: nil, headers: {}, timeout: nil)
184+
# Makes a POST request to the specified URL with JSON body and headers.
185+
# Args:
186+
# url: The endpoint URL.
187+
# json: The JSON payload to send in the request body.
188+
# headers: Additional headers for the request.
189+
# timeout: Maximum wait time for the request to respond in seconds.
190+
# Returns:
191+
# The response object.
192+
193+
response = Optimizely::Helpers::HttpUtils.make_request(url, :post, json.to_json, headers, timeout)
194+
195+
HttpResponseAdapter.new(response)
196+
end
197+
198+
class HttpResponseAdapter
199+
# Adapter for HTTP response to provide a consistent interface.
200+
# Args:
201+
# response: The raw HTTP response object.
202+
203+
def initialize(response)
204+
@response = response
205+
end
206+
207+
def status_code
208+
@response.code.to_i
209+
end
210+
211+
def json
212+
JSON.parse(@response.body)
213+
rescue JSON::ParserError
214+
raise Optimizely::CmabInvalidResponseError, Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE
215+
end
216+
217+
def body
218+
@response.body
219+
end
220+
end
221+
end
222+
223+
class NoOpLogger
224+
# A no-operation logger that does nothing.
225+
def log(_level, _message); end
226+
end
227+
end

lib/optimizely/exceptions.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,28 @@ def initialize(msg = 'Provided semantic version is invalid.')
190190
super
191191
end
192192
end
193+
194+
class CmabError < Error
195+
# Base exception for CMAB errors
196+
197+
def initialize(msg = 'CMAB error occurred.')
198+
super
199+
end
200+
end
201+
202+
class CmabFetchError < CmabError
203+
# Exception raised when CMAB fetch fails
204+
205+
def initialize(msg = 'CMAB decision fetch failed with status:')
206+
super
207+
end
208+
end
209+
210+
class CmabInvalidResponseError < CmabError
211+
# Exception raised when CMAB fetch returns an invalid response
212+
213+
def initialize(msg = 'Invalid CMAB fetch response')
214+
super
215+
end
216+
end
193217
end

lib/optimizely/helpers/constants.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,9 @@ module Constants
454454
'IF_MODIFIED_SINCE' => 'If-Modified-Since',
455455
'LAST_MODIFIED' => 'Last-Modified'
456456
}.freeze
457+
458+
CMAB_FETCH_FAILED = 'CMAB decision fetch failed (%s).'
459+
INVALID_CMAB_FETCH_RESPONSE = 'Invalid CMAB fetch response'
457460
end
458461
end
459462
end

0 commit comments

Comments
 (0)