Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
13c6a57
[FSSDK-11149] ruby: Implement CMAB Client
esrakartalOpt Jun 9, 2025
b054dc4
Created test cases
esrakartalOpt Jun 9, 2025
fd02246
Fix errors
esrakartalOpt Jun 9, 2025
c85454c
Fix lint issues
esrakartalOpt Jun 9, 2025
86e3d1f
Fix lint issues
esrakartalOpt Jun 9, 2025
b13a278
Fix lint issues
esrakartalOpt Jun 9, 2025
5c11145
Fix error
esrakartalOpt Jun 9, 2025
04c8b74
correct the name
esrakartalOpt Jun 9, 2025
04301ea
Fix test
esrakartalOpt Jun 10, 2025
7c15df5
correct the retry config
esrakartalOpt Jun 10, 2025
a29e832
Fix error
esrakartalOpt Jun 10, 2025
51b49aa
Fix logger issue
esrakartalOpt Jun 10, 2025
ee153de
Fix post error
esrakartalOpt Jun 10, 2025
ea51bc0
Correct error module name
esrakartalOpt Jun 10, 2025
c5bfa46
Fix test case
esrakartalOpt Jun 10, 2025
4681254
Fix error
esrakartalOpt Jun 10, 2025
680af03
Fix error
esrakartalOpt Jun 10, 2025
0e5164c
Remove begin
esrakartalOpt Jun 10, 2025
7d8a7fc
Fix
esrakartalOpt Jun 10, 2025
d43d36f
Fix lint
esrakartalOpt Jun 10, 2025
dd9ab16
Remove begin
esrakartalOpt Jun 10, 2025
53d5967
Fix mock response issue
esrakartalOpt Jun 10, 2025
84e5ef1
Add type
esrakartalOpt Jun 10, 2025
b2362cd
Fix errors
esrakartalOpt Jun 10, 2025
365b487
Fix error
esrakartalOpt Jun 10, 2025
36786a5
Fix test case
esrakartalOpt Jun 10, 2025
042711d
Fix test case errors
esrakartalOpt Jun 10, 2025
a5c3eb1
Fix max retry
esrakartalOpt Jun 10, 2025
50d6796
Fix
esrakartalOpt Jun 10, 2025
47e52bc
Fix logger
esrakartalOpt Jun 10, 2025
ba78601
Fix errors
esrakartalOpt Jun 10, 2025
3f13c2a
hopefully fix
esrakartalOpt Jun 10, 2025
818ad4f
Fix
esrakartalOpt Jun 10, 2025
d455d1b
Fix
esrakartalOpt Jun 10, 2025
14ac355
Reset mocks
esrakartalOpt Jun 10, 2025
f795874
Add reset to after
esrakartalOpt Jun 10, 2025
100d82e
Fix test cases
esrakartalOpt Jun 10, 2025
3affe83
Fix other issues
esrakartalOpt Jun 10, 2025
5fdbf80
remove space
esrakartalOpt Jun 10, 2025
28f30e0
Fix
esrakartalOpt Jun 10, 2025
4d1d707
correct the reset
esrakartalOpt Jun 10, 2025
5cb9de7
Update the test
esrakartalOpt Jun 10, 2025
25b7f24
Use webmock
esrakartalOpt Jun 10, 2025
0f1de66
Fix indentation
esrakartalOpt Jun 10, 2025
91a5025
fix name error
esrakartalOpt Jun 11, 2025
ef3beb8
add default http client adapter
esrakartalOpt Jun 11, 2025
c39957f
Fix errors
esrakartalOpt Jun 11, 2025
c69bec2
Update fetch retry
esrakartalOpt Jun 11, 2025
2ebb3f7
correct do_fetch method
esrakartalOpt Jun 11, 2025
fc7140d
fix lint
esrakartalOpt Jun 11, 2025
42089e3
correct the retry_config
esrakartalOpt Jun 11, 2025
ba0ad79
Add cmab error
esrakartalOpt Jun 11, 2025
b36adec
fix lint
esrakartalOpt Jun 11, 2025
63b80ca
Update backoff
esrakartalOpt Jun 11, 2025
cae486d
fix lint
esrakartalOpt Jun 11, 2025
da667ee
correct the calculation
esrakartalOpt Jun 11, 2025
154bf63
fix lint
esrakartalOpt Jun 11, 2025
c70e510
correct the calculation
esrakartalOpt Jun 11, 2025
46aad17
Fix backoff
esrakartalOpt Jun 11, 2025
90c1ee1
fix lint
esrakartalOpt Jun 11, 2025
a452983
change backoff place
esrakartalOpt Jun 11, 2025
a425f25
attempt
esrakartalOpt Jun 11, 2025
219220d
attempt 2
esrakartalOpt Jun 11, 2025
83eb987
remove attempt
esrakartalOpt Jun 11, 2025
1473c2e
add begin
esrakartalOpt Jun 11, 2025
50471c9
Convert from Python
esrakartalOpt Jun 12, 2025
5d3be53
lint issue
esrakartalOpt Jun 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions lib/optimizely/cmab/cmab_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# frozen_string_literal: true

#
# Copyright 2025 Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
require 'optimizely/helpers/http_utils'
require 'optimizely/helpers/constants'

module Optimizely
# Default constants for CMAB requests
DEFAULT_MAX_RETRIES = 3
DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
DEFAULT_MAX_BACKOFF = 10 # in seconds
DEFAULT_BACKOFF_MULTIPLIER = 2.0
MAX_WAIT_TIME = 10

class CmabRetryConfig
# Configuration for retrying CMAB requests.
# Contains parameters for maximum retries, backoff intervals, and multipliers.
attr_reader :max_retries, :initial_backoff, :max_backoff, :backoff_multiplier

def initialize(max_retries: DEFAULT_MAX_RETRIES, initial_backoff: DEFAULT_INITIAL_BACKOFF, max_backoff: DEFAULT_BACKOFF_MULTIPLIER, backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER)
@max_retries = max_retries
@initial_backoff = initial_backoff
@max_backoff = max_backoff
@backoff_multiplier = backoff_multiplier
end
end

class DefaultCmabClient
# Client for interacting with the CMAB service.
# Provides methods to fetch decisions with optional retry logic.

def initialize(http_client = nil, retry_config = nil, logger = nil)
# Initialize the CMAB client.
# Args:
# http_client: HTTP client for making requests.
# retry_config: Configuration for retry settings.
# logger: Logger for logging errors and info.
@http_client = http_client || DefaultHttpClient.new
@retry_config = retry_config || CmabRetryConfig.new
@logger = logger || NoOpLogger.new
end

def fetch_decision(rule_id, user_id, attributes, cmab_uuid, timeout: MAX_WAIT_TIME)
# Fetches a decision from the CMAB service.
# Args:
# rule_id: The rule ID for the experiment.
# user_id: The user ID for the request.
# attributes: User attributes for the request.
# cmab_uuid: Unique identifier for the CMAB request.
# timeout: Maximum wait time for the request to respond in seconds. (default is 10 seconds).
# Returns:
# The variation ID.
url = "https://prediction.cmab.optimizely.com/predict/#{rule_id}"
cmab_attributes = attributes.map { |key, value| {'id' => key.to_s, 'value' => value, 'type' => 'custom_attribute'} }

request_body = {
instances: [{
visitorId: user_id,
experimentId: rule_id,
attributes: cmab_attributes,
cmabUUID: cmab_uuid
}]
}

if @retry_config && @retry_config.max_retries.to_i.positive?
_do_fetch_with_retry(url, request_body, @retry_config, timeout)
else
_do_fetch(url, request_body, timeout)
end
end

def _do_fetch(url, request_body, timeout)
# Perform a single fetch request to the CMAB prediction service.

# Args:
# url: The endpoint URL.
# request_body: The request payload.
# timeout: Maximum wait time for the request to respond in seconds.
# Returns:
# The variation ID from the response.

headers = {'Content-Type' => 'application/json'}
begin
response = @http_client.post(url, json: request_body, headers: headers, timeout: timeout.to_i)
rescue StandardError => e
error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % e.message
@logger.log(Logger::ERROR, error_message)
raise CmabFetchError, error_message
end

unless (200..299).include?(response.status_code)
error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % response.status_code
@logger.log(Logger::ERROR, error_message)
raise CmabFetchError, error_message
end

begin
body = response.json
rescue JSON::ParserError, Optimizely::CmabInvalidResponseError
error_message = Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE
@logger.log(Logger::ERROR, error_message)
raise CmabInvalidResponseError, error_message
end

unless validate_response(body)
error_message = Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE
@logger.log(Logger::ERROR, error_message)
raise CmabInvalidResponseError, error_message
end

body['predictions'][0]['variationId']
end

def validate_response(body)
# Validate the response structure from the CMAB service.
# Args:
# body: The JSON response body to validate.
# Returns:
# true if valid, false otherwise.

body.is_a?(Hash) &&
body.key?('predictions') &&
body['predictions'].is_a?(Array) &&
!body['predictions'].empty? &&
body['predictions'][0].is_a?(Hash) &&
body['predictions'][0].key?('variationId')
end

def _do_fetch_with_retry(url, request_body, retry_config, timeout)
# Perform a fetch request with retry logic.
# Args:
# url: The endpoint URL.
# request_body: The request payload.
# retry_config: Configuration for retry settings.
# timeout: Maximum wait time for the request to respond in seconds.
# Returns:
# The variation ID from the response.

backoff = retry_config.initial_backoff

(0..retry_config.max_retries).each do |attempt|
variation_id = _do_fetch(url, request_body, timeout)
return variation_id
rescue StandardError => e
if attempt < retry_config.max_retries
@logger.log(Logger::INFO, "Retrying CMAB request (attempt #{attempt + 1}) after #{backoff} seconds...")
Kernel.sleep(backoff)

backoff = [
backoff * (retry_config.backoff_multiplier**(attempt + 1)),
retry_config.max_backoff
].min
else
@logger.log(Logger::ERROR, "Max retries exceeded for CMAB request: #{e.message}")
raise Optimizely::CmabFetchError, "CMAB decision fetch failed (#{e.message})."
end
end

error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % 'Exhausted all retries for CMAB request.'
@logger.log(Logger::ERROR, error_message)
raise Optimizely::CmabFetchError, error_message
end
end

class DefaultHttpClient
# Default HTTP client for making requests.
# Uses Optimizely::Helpers::HttpUtils to make requests.

def post(url, json: nil, headers: {}, timeout: nil)
# Makes a POST request to the specified URL with JSON body and headers.
# Args:
# url: The endpoint URL.
# json: The JSON payload to send in the request body.
# headers: Additional headers for the request.
# timeout: Maximum wait time for the request to respond in seconds.
# Returns:
# The response object.

response = Optimizely::Helpers::HttpUtils.make_request(url, :post, json.to_json, headers, timeout)

HttpResponseAdapter.new(response)
end

class HttpResponseAdapter
# Adapter for HTTP response to provide a consistent interface.
# Args:
# response: The raw HTTP response object.

def initialize(response)
@response = response
end

def status_code
@response.code.to_i
end

def json
JSON.parse(@response.body)
rescue JSON::ParserError
raise Optimizely::CmabInvalidResponseError, Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE
end

def body
@response.body
end
end
end

class NoOpLogger
# A no-operation logger that does nothing.
def log(_level, _message); end
end
end
24 changes: 24 additions & 0 deletions lib/optimizely/exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,28 @@ def initialize(msg = 'Provided semantic version is invalid.')
super
end
end

class CmabError < Error
# Base exception for CMAB errors

def initialize(msg = 'CMAB error occurred.')
super
end
end

class CmabFetchError < CmabError
# Exception raised when CMAB fetch fails

def initialize(msg = 'CMAB decision fetch failed with status:')
super
end
end

class CmabInvalidResponseError < CmabError
# Exception raised when CMAB fetch returns an invalid response

def initialize(msg = 'Invalid CMAB fetch response')
super
end
end
end
3 changes: 3 additions & 0 deletions lib/optimizely/helpers/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,9 @@ module Constants
'IF_MODIFIED_SINCE' => 'If-Modified-Since',
'LAST_MODIFIED' => 'Last-Modified'
}.freeze

CMAB_FETCH_FAILED = 'CMAB decision fetch failed (%s).'
INVALID_CMAB_FETCH_RESPONSE = 'Invalid CMAB fetch response'
end
end
end
Loading