optimizely-sdk 3.4.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2017, 2019, Optimizely and contributors
4
+ # Copyright 2016-2017, 2019-2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -24,38 +24,37 @@ module Optimizely
24
24
  module Audience
25
25
  module_function
26
26
 
27
- def user_in_experiment?(config, experiment, attributes, logger)
28
- # Determine for given experiment if user satisfies the audiences for the experiment.
27
+ def user_meets_audience_conditions?(config, experiment, attributes, logger, logging_hash = nil, logging_key = nil)
28
+ # Determine for given experiment/rollout rule if user satisfies the audience conditions.
29
29
  #
30
30
  # config - Representation of the Optimizely project config.
31
- # experiment - Experiment for which visitor is to be bucketed.
31
+ # experiment - Experiment/Rollout rule in which user is to be bucketed.
32
32
  # attributes - Hash representing user attributes which will be used in determining if
33
33
  # the audience conditions are met.
34
+ # logger - Provides a logger instance.
35
+ # logging_hash - Optional string representing logs hash inside Helpers::Constants.
36
+ # This defaults to 'EXPERIMENT_AUDIENCE_EVALUATION_LOGS'.
37
+ # logging_key - Optional string to be logged as an identifier of experiment under evaluation.
38
+ # This defaults to experiment['key'].
34
39
  #
35
40
  # Returns boolean representing if user satisfies audience conditions for the audiences or not.
41
+ decide_reasons = []
42
+ logging_hash ||= 'EXPERIMENT_AUDIENCE_EVALUATION_LOGS'
43
+ logging_key ||= experiment['key']
44
+
45
+ logs_hash = Object.const_get "Optimizely::Helpers::Constants::#{logging_hash}"
36
46
 
37
47
  audience_conditions = experiment['audienceConditions'] || experiment['audienceIds']
38
48
 
39
- logger.log(
40
- Logger::DEBUG,
41
- format(
42
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['EVALUATING_AUDIENCES_COMBINED'],
43
- experiment['key'],
44
- audience_conditions
45
- )
46
- )
49
+ message = format(logs_hash['EVALUATING_AUDIENCES_COMBINED'], logging_key, audience_conditions)
50
+ logger.log(Logger::DEBUG, message)
47
51
 
48
52
  # Return true if there are no audiences
49
53
  if audience_conditions.empty?
50
- logger.log(
51
- Logger::INFO,
52
- format(
53
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['AUDIENCE_EVALUATION_RESULT_COMBINED'],
54
- experiment['key'],
55
- 'TRUE'
56
- )
57
- )
58
- return true
54
+ message = format(logs_hash['AUDIENCE_EVALUATION_RESULT_COMBINED'], logging_key, 'TRUE')
55
+ logger.log(Logger::INFO, message)
56
+ decide_reasons.push(message)
57
+ return true, decide_reasons
59
58
  end
60
59
 
61
60
  attributes ||= {}
@@ -71,39 +70,28 @@ module Optimizely
71
70
  return nil unless audience
72
71
 
73
72
  audience_conditions = audience['conditions']
74
- logger.log(
75
- Logger::DEBUG,
76
- format(
77
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['EVALUATING_AUDIENCE'],
78
- audience_id,
79
- audience_conditions
80
- )
81
- )
73
+ message = format(logs_hash['EVALUATING_AUDIENCE'], audience_id, audience_conditions)
74
+ logger.log(Logger::DEBUG, message)
75
+ decide_reasons.push(message)
82
76
 
83
77
  audience_conditions = JSON.parse(audience_conditions) if audience_conditions.is_a?(String)
84
78
  result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_custom_attr)
85
79
  result_str = result.nil? ? 'UNKNOWN' : result.to_s.upcase
86
- logger.log(
87
- Logger::INFO,
88
- format(Helpers::Constants::AUDIENCE_EVALUATION_LOGS['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
89
- )
80
+ message = format(logs_hash['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
81
+ logger.log(Logger::DEBUG, message)
82
+ decide_reasons.push(message)
83
+
90
84
  result
91
85
  end
92
86
 
93
87
  eval_result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_audience)
94
-
95
88
  eval_result ||= false
96
89
 
97
- logger.log(
98
- Logger::INFO,
99
- format(
100
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['AUDIENCE_EVALUATION_RESULT_COMBINED'],
101
- experiment['key'],
102
- eval_result.to_s.upcase
103
- )
104
- )
90
+ message = format(logs_hash['AUDIENCE_EVALUATION_RESULT_COMBINED'], logging_key, eval_result.to_s.upcase)
91
+ logger.log(Logger::INFO, message)
92
+ decide_reasons.push(message)
105
93
 
106
- eval_result
94
+ [eval_result, decide_reasons]
107
95
  end
108
96
  end
109
97
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2017, 2019 Optimizely and contributors
4
+ # Copyright 2016-2017, 2019-2020 Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -39,14 +39,17 @@ module Optimizely
39
39
  # Determines ID of variation to be shown for a given experiment key and user ID.
40
40
  #
41
41
  # project_config - Instance of ProjectConfig
42
- # experiment - Experiment for which visitor is to be bucketed.
42
+ # experiment - Experiment or Rollout rule for which visitor is to be bucketed.
43
43
  # bucketing_id - String A customer-assigned value used to generate the bucketing key
44
44
  # user_id - String ID for user.
45
45
  #
46
46
  # Returns variation in which visitor with ID user_id has been placed. Nil if no variation.
47
- return nil if experiment.nil?
47
+ return nil, [] if experiment.nil?
48
+
49
+ decide_reasons = []
48
50
 
49
51
  # check if experiment is in a group; if so, check if user is bucketed into specified experiment
52
+ # this will not affect evaluation of rollout rules.
50
53
  experiment_id = experiment['id']
51
54
  experiment_key = experiment['key']
52
55
  group_id = experiment['groupId']
@@ -54,52 +57,49 @@ module Optimizely
54
57
  group = project_config.group_id_map.fetch(group_id)
55
58
  if Helpers::Group.random_policy?(group)
56
59
  traffic_allocations = group.fetch('trafficAllocation')
57
- bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
60
+ bucketed_experiment_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
61
+ decide_reasons.push(*find_bucket_reasons)
62
+
58
63
  # return if the user is not bucketed into any experiment
59
64
  unless bucketed_experiment_id
60
- @logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
61
- return nil
65
+ message = "User '#{user_id}' is in no experiment."
66
+ @logger.log(Logger::INFO, message)
67
+ decide_reasons.push(message)
68
+ return nil, decide_reasons
62
69
  end
63
70
 
64
71
  # return if the user is bucketed into a different experiment than the one specified
65
72
  if bucketed_experiment_id != experiment_id
66
- @logger.log(
67
- Logger::INFO,
68
- "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
69
- )
70
- return nil
73
+ message = "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
74
+ @logger.log(Logger::INFO, message)
75
+ decide_reasons.push(message)
76
+ return nil, decide_reasons
71
77
  end
72
78
 
73
79
  # continue bucketing if the user is bucketed into the experiment specified
74
- @logger.log(
75
- Logger::INFO,
76
- "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
77
- )
80
+ message = "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
81
+ @logger.log(Logger::INFO, message)
82
+ decide_reasons.push(message)
78
83
  end
79
84
  end
80
85
 
81
86
  traffic_allocations = experiment['trafficAllocation']
82
- variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
87
+ variation_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
88
+ decide_reasons.push(*find_bucket_reasons)
89
+
83
90
  if variation_id && variation_id != ''
84
91
  variation = project_config.get_variation_from_id(experiment_key, variation_id)
85
- variation_key = variation ? variation['key'] : nil
86
- @logger.log(
87
- Logger::INFO,
88
- "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
89
- )
90
- return variation
92
+ return variation, decide_reasons
91
93
  end
92
94
 
93
95
  # Handle the case when the traffic range is empty due to sticky bucketing
94
96
  if variation_id == ''
95
- @logger.log(
96
- Logger::DEBUG,
97
- 'Bucketed into an empty traffic range. Returning nil.'
98
- )
97
+ message = 'Bucketed into an empty traffic range. Returning nil.'
98
+ @logger.log(Logger::DEBUG, message)
99
+ decide_reasons.push(message)
99
100
  end
100
101
 
101
- @logger.log(Logger::INFO, "User '#{user_id}' is in no variation.")
102
- nil
102
+ [nil, decide_reasons]
103
103
  end
104
104
 
105
105
  def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
@@ -110,21 +110,24 @@ module Optimizely
110
110
  # parent_id - String entity ID to use for bucketing ID
111
111
  # traffic_allocations - Array of traffic allocations
112
112
  #
113
- # Returns entity ID corresponding to the provided bucket value or nil if no match is found.
113
+ # Returns and array of two values where first value is the entity ID corresponding to the provided bucket value
114
+ # or nil if no match is found. The second value contains the array of reasons stating how the deicision was taken
115
+ decide_reasons = []
114
116
  bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
115
117
  bucket_value = generate_bucket_value(bucketing_key)
116
- @logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' "\
117
- "with bucketing ID: '#{bucketing_id}'.")
118
+
119
+ message = "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'."
120
+ @logger.log(Logger::DEBUG, message)
118
121
 
119
122
  traffic_allocations.each do |traffic_allocation|
120
123
  current_end_of_range = traffic_allocation['endOfRange']
121
124
  if bucket_value < current_end_of_range
122
125
  entity_id = traffic_allocation['entityId']
123
- return entity_id
126
+ return entity_id, decide_reasons
124
127
  end
125
128
  end
126
129
 
127
- nil
130
+ [nil, decide_reasons]
128
131
  end
129
132
 
130
133
  private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2019, Optimizely and contributors
3
+ # Copyright 2019-2020, Optimizely and contributors
4
4
  #
5
5
  # Licensed under the Apache License, Version 2.0 (the "License");
6
6
  # you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@ module Optimizely
24
24
  RUNNING_EXPERIMENT_STATUS = ['Running'].freeze
25
25
  RESERVED_ATTRIBUTE_PREFIX = '$opt_'
26
26
 
27
+ attr_reader :datafile
27
28
  attr_reader :account_id
28
29
  attr_reader :attributes
29
30
  attr_reader :audiences
@@ -39,6 +40,7 @@ module Optimizely
39
40
  attr_reader :revision
40
41
  attr_reader :rollouts
41
42
  attr_reader :version
43
+ attr_reader :send_flag_decisions
42
44
 
43
45
  attr_reader :attribute_key_map
44
46
  attr_reader :audience_id_map
@@ -62,6 +64,7 @@ module Optimizely
62
64
 
63
65
  config = JSON.parse(datafile)
64
66
 
67
+ @datafile = datafile
65
68
  @error_handler = error_handler
66
69
  @logger = logger
67
70
  @version = config['version']
@@ -81,6 +84,18 @@ module Optimizely
81
84
  @bot_filtering = config['botFiltering']
82
85
  @revision = config['revision']
83
86
  @rollouts = config.fetch('rollouts', [])
87
+ @send_flag_decisions = config.fetch('sendFlagDecisions', false)
88
+
89
+ # Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
90
+ # Converting it to a first-class json type while creating Project Config
91
+ @feature_flags.each do |feature_flag|
92
+ feature_flag['variables'].each do |variable|
93
+ if variable['type'] == 'string' && variable['subType'] == 'json'
94
+ variable['type'] = 'json'
95
+ variable.delete('subType')
96
+ end
97
+ end
98
+ end
84
99
 
85
100
  # Utility maps for quick lookup
86
101
  @attribute_key_map = generate_key_map(@attributes, 'key')
@@ -159,8 +174,9 @@ module Optimizely
159
174
  config = new(datafile, logger, error_handler)
160
175
  rescue StandardError => e
161
176
  default_logger = SimpleLogger.new
162
- error_msg = e.class == InvalidDatafileVersionError ? e.message : InvalidInputError.new('datafile').message
163
- error_to_handle = e.class == InvalidDatafileVersionError ? InvalidDatafileVersionError : InvalidInputError
177
+ error_to_handle = e.class == InvalidDatafileVersionError ? e : InvalidInputError.new('datafile')
178
+ error_msg = error_to_handle.message
179
+
164
180
  default_logger.log(Logger::ERROR, error_msg)
165
181
  error_handler.handle_error error_to_handle
166
182
  return nil
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2020, Optimizely and contributors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ #
18
+
19
+ module Optimizely
20
+ class ProxyConfig
21
+ attr_reader :host, :port, :username, :password
22
+
23
+ def initialize(host, port = nil, username = nil, password = nil)
24
+ # host - DNS name or IP address of proxy
25
+ # port - port to use to acess the proxy
26
+ # username - username if authorization is required
27
+ # password - password if authorization is required
28
+ @host = host
29
+ @port = port
30
+ @username = username
31
+ @password = password
32
+ end
33
+ end
34
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2019, Optimizely and contributors
4
+ # Copyright 2019-2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -19,13 +19,14 @@ module Optimizely
19
19
  class AsyncScheduler
20
20
  attr_reader :running
21
21
 
22
- def initialize(callback, interval, auto_update, logger = nil)
22
+ def initialize(callback, interval, auto_update, logger = nil, error_handler = nil)
23
23
  # Sets up AsyncScheduler to execute a callback periodically.
24
24
  #
25
25
  # callback - Main function to be executed periodically.
26
26
  # interval - How many seconds to wait between executions.
27
27
  # auto_update - boolean indicates to run infinitely or only once.
28
28
  # logger - Optional Provides a logger instance.
29
+ # error_handler - Optional Provides a handle_error method to handle exceptions.
29
30
 
30
31
  @callback = callback
31
32
  @interval = interval
@@ -33,6 +34,7 @@ module Optimizely
33
34
  @running = false
34
35
  @thread = nil
35
36
  @logger = logger || NoOpLogger.new
37
+ @error_handler = error_handler || NoOpErrorHandler.new
36
38
  end
37
39
 
38
40
  def start!
@@ -54,6 +56,7 @@ module Optimizely
54
56
  Logger::ERROR,
55
57
  "Couldn't create a new thread for async scheduler. #{e.message}"
56
58
  )
59
+ @error_handler.handle_error(e)
57
60
  end
58
61
  end
59
62
 
@@ -80,6 +83,7 @@ module Optimizely
80
83
  Logger::ERROR,
81
84
  "Something went wrong when executing passed callback. #{e.message}"
82
85
  )
86
+ @error_handler.handle_error(e)
83
87
  stop!
84
88
  end
85
89
  break unless @auto_update
@@ -19,14 +19,16 @@ require_relative '../config/datafile_project_config'
19
19
  require_relative '../error_handler'
20
20
  require_relative '../exceptions'
21
21
  require_relative '../helpers/constants'
22
+ require_relative '../helpers/http_utils'
22
23
  require_relative '../logger'
23
24
  require_relative '../notification_center'
24
25
  require_relative '../project_config'
25
26
  require_relative '../optimizely_config'
26
27
  require_relative 'project_config_manager'
27
28
  require_relative 'async_scheduler'
28
- require 'httparty'
29
+
29
30
  require 'json'
31
+
30
32
  module Optimizely
31
33
  class HTTPProjectConfigManager < ProjectConfigManager
32
34
  # Config manager that polls for the datafile and updated ProjectConfig based on an update interval.
@@ -49,6 +51,8 @@ module Optimizely
49
51
  # error_handler - Provides a handle_error method to handle exceptions.
50
52
  # skip_json_validation - Optional boolean param which allows skipping JSON schema
51
53
  # validation upon object invocation. By default JSON schema validation will be performed.
54
+ # datafile_access_token - access token used to fetch private datafiles
55
+ # proxy_config - Optional proxy config instancea to configure making web requests through a proxy server.
52
56
  def initialize(
53
57
  sdk_key: nil,
54
58
  url: nil,
@@ -61,10 +65,13 @@ module Optimizely
61
65
  logger: nil,
62
66
  error_handler: nil,
63
67
  skip_json_validation: false,
64
- notification_center: nil
68
+ notification_center: nil,
69
+ datafile_access_token: nil,
70
+ proxy_config: nil
65
71
  )
66
72
  @logger = logger || NoOpLogger.new
67
73
  @error_handler = error_handler || NoOpErrorHandler.new
74
+ @access_token = datafile_access_token
68
75
  @datafile_url = get_datafile_url(sdk_key, url, url_template)
69
76
  @polling_interval = nil
70
77
  polling_interval(polling_interval)
@@ -81,6 +88,7 @@ module Optimizely
81
88
  # Start async scheduler in the end to avoid race condition where scheduler executes
82
89
  # callback which makes use of variables not yet initialized by the main thread.
83
90
  @async_scheduler.start! if start_by_default == true
91
+ @proxy_config = proxy_config
84
92
  @stopped = false
85
93
  end
86
94
 
@@ -143,21 +151,20 @@ module Optimizely
143
151
  end
144
152
 
145
153
  def request_config
146
- @logger.log(
147
- Logger::DEBUG,
148
- "Fetching datafile from #{@datafile_url}"
149
- )
150
- begin
151
- headers = {
152
- 'Content-Type' => 'application/json'
153
- }
154
+ @logger.log(Logger::DEBUG, "Fetching datafile from #{@datafile_url}")
155
+ headers = {}
156
+ headers['Content-Type'] = 'application/json'
157
+ headers['If-Modified-Since'] = @last_modified if @last_modified
158
+ headers['Authorization'] = "Bearer #{@access_token}" unless @access_token.nil?
154
159
 
155
- headers[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']] = @last_modified if @last_modified
160
+ # Cleaning headers before logging to avoid exposing authorization token
161
+ cleansed_headers = {}
162
+ headers.each { |key, value| cleansed_headers[key] = key == 'Authorization' ? '********' : value }
163
+ @logger.log(Logger::DEBUG, "Datafile request headers: #{cleansed_headers}")
156
164
 
157
- response = HTTParty.get(
158
- @datafile_url,
159
- headers: headers,
160
- timeout: Helpers::Constants::CONFIG_MANAGER['REQUEST_TIMEOUT']
165
+ begin
166
+ response = Helpers::HttpUtils.make_request(
167
+ @datafile_url, :get, nil, headers, Helpers::Constants::CONFIG_MANAGER['REQUEST_TIMEOUT'], @proxy_config
161
168
  )
162
169
  rescue StandardError => e
163
170
  @logger.log(
@@ -167,6 +174,9 @@ module Optimizely
167
174
  return nil
168
175
  end
169
176
 
177
+ response_code = response.code.to_i
178
+ @logger.log(Logger::DEBUG, "Datafile response status code #{response_code}")
179
+
170
180
  # Leave datafile and config unchanged if it has not been modified.
171
181
  if response.code == '304'
172
182
  @logger.log(
@@ -176,9 +186,14 @@ module Optimizely
176
186
  return
177
187
  end
178
188
 
179
- @last_modified = response[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']]
180
-
181
- config = DatafileProjectConfig.create(response.body, @logger, @error_handler, @skip_json_validation) if response.body
189
+ if response_code >= 200 && response_code < 400
190
+ @logger.log(Logger::DEBUG, 'Successfully fetched datafile, generating Project config')
191
+ config = DatafileProjectConfig.create(response.body, @logger, @error_handler, @skip_json_validation)
192
+ @last_modified = response[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']]
193
+ @logger.log(Logger::DEBUG, "Saved last modified header value from response: #{@last_modified}.")
194
+ else
195
+ @logger.log(Logger::DEBUG, "Datafile fetch failed, status: #{response.code}, message: #{response.message}")
196
+ end
182
197
 
183
198
  config
184
199
  end
@@ -284,17 +299,19 @@ module Optimizely
284
299
  # SDK key to determine URL from which to fetch the datafile.
285
300
  # Returns String representing URL to fetch datafile from.
286
301
  if sdk_key.nil? && url.nil?
287
- @logger.log(Logger::ERROR, 'Must provide at least one of sdk_key or url.')
288
- @error_handler.handle_error(InvalidInputsError)
302
+ error_msg = 'Must provide at least one of sdk_key or url.'
303
+ @logger.log(Logger::ERROR, error_msg)
304
+ @error_handler.handle_error(InvalidInputsError.new(error_msg))
289
305
  end
290
306
 
291
307
  unless url
292
- url_template ||= Helpers::Constants::CONFIG_MANAGER['DATAFILE_URL_TEMPLATE']
308
+ url_template ||= @access_token.nil? ? Helpers::Constants::CONFIG_MANAGER['DATAFILE_URL_TEMPLATE'] : Helpers::Constants::CONFIG_MANAGER['AUTHENTICATED_DATAFILE_URL_TEMPLATE']
293
309
  begin
294
310
  return (url_template % sdk_key)
295
311
  rescue
296
- @logger.log(Logger::ERROR, "Invalid url_template #{url_template} provided.")
297
- @error_handler.handle_error(InvalidInputsError)
312
+ error_msg = "Invalid url_template #{url_template} provided."
313
+ @logger.log(Logger::ERROR, error_msg)
314
+ @error_handler.handle_error(InvalidInputsError.new(error_msg))
298
315
  end
299
316
  end
300
317