optimizely-sdk 3.3.2 → 3.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/lib/optimizely.rb +248 -46
- data/lib/optimizely/audience.rb +22 -13
- data/lib/optimizely/bucketer.rb +3 -8
- data/lib/optimizely/config/datafile_project_config.rb +19 -3
- data/lib/optimizely/config/proxy_config.rb +34 -0
- data/lib/optimizely/config_manager/async_scheduler.rb +6 -2
- data/lib/optimizely/config_manager/http_project_config_manager.rb +45 -25
- data/lib/optimizely/config_manager/static_project_config_manager.rb +6 -2
- data/lib/optimizely/custom_attribute_condition_evaluator.rb +133 -37
- data/lib/optimizely/decision_service.rb +31 -29
- data/lib/optimizely/event/batch_event_processor.rb +47 -39
- data/lib/optimizely/event/entity/decision.rb +6 -4
- data/lib/optimizely/event/entity/impression_event.rb +4 -2
- data/lib/optimizely/event/event_factory.rb +4 -3
- data/lib/optimizely/event/user_event_factory.rb +4 -3
- data/lib/optimizely/event_dispatcher.rb +8 -14
- data/lib/optimizely/exceptions.rb +17 -9
- data/lib/optimizely/helpers/constants.rb +18 -5
- data/lib/optimizely/helpers/http_utils.rb +64 -0
- data/lib/optimizely/helpers/variable_type.rb +8 -1
- data/lib/optimizely/optimizely_config.rb +117 -0
- data/lib/optimizely/optimizely_factory.rb +54 -5
- data/lib/optimizely/project_config.rb +5 -1
- data/lib/optimizely/semantic_version.rb +166 -0
- data/lib/optimizely/version.rb +1 -1
- metadata +7 -18
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
#
|
4
|
-
# Copyright 2017-
|
4
|
+
# Copyright 2017-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.
|
@@ -40,6 +40,7 @@ module Optimizely
|
|
40
40
|
Decision = Struct.new(:experiment, :variation, :source)
|
41
41
|
|
42
42
|
DECISION_SOURCES = {
|
43
|
+
'EXPERIMENT' => 'experiment',
|
43
44
|
'FEATURE_TEST' => 'feature-test',
|
44
45
|
'ROLLOUT' => 'rollout'
|
45
46
|
}.freeze
|
@@ -94,7 +95,7 @@ module Optimizely
|
|
94
95
|
end
|
95
96
|
|
96
97
|
# Check audience conditions
|
97
|
-
unless Audience.
|
98
|
+
unless Audience.user_meets_audience_conditions?(project_config, experiment, attributes, @logger)
|
98
99
|
@logger.log(
|
99
100
|
Logger::INFO,
|
100
101
|
"User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
|
@@ -106,6 +107,16 @@ module Optimizely
|
|
106
107
|
variation = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
|
107
108
|
variation_id = variation ? variation['id'] : nil
|
108
109
|
|
110
|
+
if variation_id
|
111
|
+
variation_key = variation['key']
|
112
|
+
@logger.log(
|
113
|
+
Logger::INFO,
|
114
|
+
"User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
|
115
|
+
)
|
116
|
+
else
|
117
|
+
@logger.log(Logger::INFO, "User '#{user_id}' is in no variation.")
|
118
|
+
end
|
119
|
+
|
109
120
|
# Persist bucketing decision
|
110
121
|
save_user_profile(user_profile, experiment_id, variation_id)
|
111
122
|
variation_id
|
@@ -125,21 +136,9 @@ module Optimizely
|
|
125
136
|
decision = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes)
|
126
137
|
return decision unless decision.nil?
|
127
138
|
|
128
|
-
feature_flag_key = feature_flag['key']
|
129
139
|
decision = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
|
130
|
-
if decision
|
131
|
-
@logger.log(
|
132
|
-
Logger::INFO,
|
133
|
-
"User '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag_key}'."
|
134
|
-
)
|
135
|
-
return decision
|
136
|
-
end
|
137
|
-
@logger.log(
|
138
|
-
Logger::INFO,
|
139
|
-
"User '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag_key}'."
|
140
|
-
)
|
141
140
|
|
142
|
-
|
141
|
+
decision
|
143
142
|
end
|
144
143
|
|
145
144
|
def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil)
|
@@ -178,10 +177,7 @@ module Optimizely
|
|
178
177
|
next unless variation_id
|
179
178
|
|
180
179
|
variation = project_config.variation_id_map[experiment_key][variation_id]
|
181
|
-
|
182
|
-
Logger::INFO,
|
183
|
-
"The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
|
184
|
-
)
|
180
|
+
|
185
181
|
return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'])
|
186
182
|
end
|
187
183
|
|
@@ -231,20 +227,23 @@ module Optimizely
|
|
231
227
|
# Go through each experiment in order and try to get the variation for the user
|
232
228
|
number_of_rules.times do |index|
|
233
229
|
rollout_rule = rollout_rules[index]
|
234
|
-
|
235
|
-
audience = project_config.get_audience_from_id(audience_id)
|
236
|
-
audience_name = audience['name']
|
230
|
+
logging_key = index + 1
|
237
231
|
|
238
232
|
# Check that user meets audience conditions for targeting rule
|
239
|
-
unless Audience.
|
233
|
+
unless Audience.user_meets_audience_conditions?(project_config, rollout_rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
|
240
234
|
@logger.log(
|
241
235
|
Logger::DEBUG,
|
242
|
-
"User '#{user_id}' does not meet the conditions
|
236
|
+
"User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
|
243
237
|
)
|
244
238
|
# move onto the next targeting rule
|
245
239
|
next
|
246
240
|
end
|
247
241
|
|
242
|
+
@logger.log(
|
243
|
+
Logger::DEBUG,
|
244
|
+
"User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
|
245
|
+
)
|
246
|
+
|
248
247
|
# Evaluate if user satisfies the traffic allocation for this rollout rule
|
249
248
|
variation = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id)
|
250
249
|
return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
|
@@ -254,17 +253,20 @@ module Optimizely
|
|
254
253
|
|
255
254
|
# get last rule which is the everyone else rule
|
256
255
|
everyone_else_experiment = rollout_rules[number_of_rules]
|
256
|
+
logging_key = 'Everyone Else'
|
257
257
|
# Check that user meets audience conditions for last rule
|
258
|
-
unless Audience.
|
259
|
-
audience_id = everyone_else_experiment['audienceIds'][0]
|
260
|
-
audience = project_config.get_audience_from_id(audience_id)
|
261
|
-
audience_name = audience['name']
|
258
|
+
unless Audience.user_meets_audience_conditions?(project_config, everyone_else_experiment, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
|
262
259
|
@logger.log(
|
263
260
|
Logger::DEBUG,
|
264
|
-
"User '#{user_id}' does not meet the conditions
|
261
|
+
"User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
|
265
262
|
)
|
266
263
|
return nil
|
267
264
|
end
|
265
|
+
|
266
|
+
@logger.log(
|
267
|
+
Logger::DEBUG,
|
268
|
+
"User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
|
269
|
+
)
|
268
270
|
variation = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id)
|
269
271
|
return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
|
270
272
|
|
@@ -31,7 +31,6 @@ module Optimizely
|
|
31
31
|
DEFAULT_BATCH_INTERVAL = 30_000 # interval in milliseconds
|
32
32
|
DEFAULT_QUEUE_CAPACITY = 1000
|
33
33
|
DEFAULT_TIMEOUT_INTERVAL = 5 # interval in seconds
|
34
|
-
MAX_NIL_COUNT = 3
|
35
34
|
|
36
35
|
FLUSH_SIGNAL = 'FLUSH_SIGNAL'
|
37
36
|
SHUTDOWN_SIGNAL = 'SHUTDOWN_SIGNAL'
|
@@ -62,7 +61,7 @@ module Optimizely
|
|
62
61
|
@notification_center = notification_center
|
63
62
|
@current_batch = []
|
64
63
|
@started = false
|
65
|
-
|
64
|
+
@stopped = false
|
66
65
|
end
|
67
66
|
|
68
67
|
def start!
|
@@ -72,26 +71,37 @@ module Optimizely
|
|
72
71
|
end
|
73
72
|
@flushing_interval_deadline = Helpers::DateTimeUtils.create_timestamp + @flush_interval
|
74
73
|
@logger.log(Logger::INFO, 'Starting scheduler.')
|
75
|
-
@
|
74
|
+
if @wait_mutex.nil?
|
75
|
+
@wait_mutex = Mutex.new
|
76
|
+
@resource = ConditionVariable.new
|
77
|
+
end
|
78
|
+
@thread = Thread.new { run_queue }
|
76
79
|
@started = true
|
80
|
+
@stopped = false
|
77
81
|
end
|
78
82
|
|
79
83
|
def flush
|
80
84
|
@event_queue << FLUSH_SIGNAL
|
85
|
+
@wait_mutex.synchronize { @resource.signal }
|
81
86
|
end
|
82
87
|
|
83
88
|
def process(user_event)
|
84
89
|
@logger.log(Logger::DEBUG, "Received userEvent: #{user_event}")
|
85
90
|
|
86
|
-
if
|
91
|
+
# if the processor has been explicitly stopped. Don't accept tasks
|
92
|
+
if @stopped
|
87
93
|
@logger.log(Logger::WARN, 'Executor shutdown, not accepting tasks.')
|
88
94
|
return
|
89
95
|
end
|
90
96
|
|
97
|
+
# start if the processor hasn't been started
|
98
|
+
start! unless @started
|
99
|
+
|
91
100
|
begin
|
92
101
|
@event_queue.push(user_event, true)
|
93
|
-
|
94
|
-
|
102
|
+
@wait_mutex.synchronize { @resource.signal }
|
103
|
+
rescue => e
|
104
|
+
@logger.log(Logger::WARN, 'Payload not accepted by the queue: ' + e.message)
|
95
105
|
return
|
96
106
|
end
|
97
107
|
end
|
@@ -101,42 +111,20 @@ module Optimizely
|
|
101
111
|
|
102
112
|
@logger.log(Logger::INFO, 'Stopping scheduler.')
|
103
113
|
@event_queue << SHUTDOWN_SIGNAL
|
114
|
+
@wait_mutex.synchronize { @resource.signal }
|
104
115
|
@thread.join(DEFAULT_TIMEOUT_INTERVAL)
|
105
116
|
@started = false
|
117
|
+
@stopped = true
|
106
118
|
end
|
107
119
|
|
108
120
|
private
|
109
121
|
|
110
|
-
def
|
111
|
-
|
112
|
-
|
113
|
-
@nil_count = 0
|
114
|
-
# hang on pop if true
|
115
|
-
@use_pop = false
|
116
|
-
loop do
|
117
|
-
if Helpers::DateTimeUtils.create_timestamp >= @flushing_interval_deadline
|
118
|
-
@logger.log(Logger::DEBUG, 'Deadline exceeded flushing current batch.')
|
119
|
-
flush_queue!
|
120
|
-
@flushing_interval_deadline = Helpers::DateTimeUtils.create_timestamp + @flush_interval
|
121
|
-
@use_pop = true if @nil_count > MAX_NIL_COUNT
|
122
|
-
end
|
123
|
-
|
124
|
-
item = @event_queue.pop if @event_queue.length.positive? || @use_pop
|
125
|
-
|
126
|
-
if item.nil?
|
127
|
-
# when nil count is greater than MAX_NIL_COUNT, we hang on the pop until there is an item available.
|
128
|
-
# this avoids to much spinning of the loop.
|
129
|
-
@nil_count += 1
|
130
|
-
next
|
131
|
-
end
|
132
|
-
|
133
|
-
# reset nil_count and use_pop if we have received an item.
|
134
|
-
@nil_count = 0
|
135
|
-
@use_pop = false
|
136
|
-
|
122
|
+
def process_queue
|
123
|
+
while @event_queue.length.positive?
|
124
|
+
item = @event_queue.pop
|
137
125
|
if item == SHUTDOWN_SIGNAL
|
138
126
|
@logger.log(Logger::DEBUG, 'Received shutdown signal.')
|
139
|
-
|
127
|
+
return false
|
140
128
|
end
|
141
129
|
|
142
130
|
if item == FLUSH_SIGNAL
|
@@ -147,15 +135,35 @@ module Optimizely
|
|
147
135
|
|
148
136
|
add_to_batch(item) if item.is_a? Optimizely::UserEvent
|
149
137
|
end
|
138
|
+
true
|
139
|
+
end
|
140
|
+
|
141
|
+
def run_queue
|
142
|
+
loop do
|
143
|
+
if Helpers::DateTimeUtils.create_timestamp >= @flushing_interval_deadline
|
144
|
+
@logger.log(Logger::DEBUG, 'Deadline exceeded flushing current batch.')
|
145
|
+
|
146
|
+
break unless process_queue
|
147
|
+
|
148
|
+
flush_queue!
|
149
|
+
@flushing_interval_deadline = Helpers::DateTimeUtils.create_timestamp + @flush_interval
|
150
|
+
end
|
151
|
+
|
152
|
+
break unless process_queue
|
153
|
+
|
154
|
+
# what is the current interval to flush in seconds
|
155
|
+
interval = (@flushing_interval_deadline - Helpers::DateTimeUtils.create_timestamp) * 0.001
|
156
|
+
|
157
|
+
next unless interval.positive?
|
158
|
+
|
159
|
+
@wait_mutex.synchronize { @resource.wait(@wait_mutex, interval) }
|
160
|
+
end
|
150
161
|
rescue SignalException
|
151
162
|
@logger.log(Logger::ERROR, 'Interrupted while processing buffer.')
|
152
|
-
rescue
|
163
|
+
rescue => e
|
153
164
|
@logger.log(Logger::ERROR, "Uncaught exception processing buffer. #{e.message}")
|
154
165
|
ensure
|
155
|
-
@logger.log(
|
156
|
-
Logger::INFO,
|
157
|
-
'Exiting processing loop. Attempting to flush pending events.'
|
158
|
-
)
|
166
|
+
@logger.log(Logger::INFO, 'Exiting processing loop. Attempting to flush pending events.')
|
159
167
|
flush_queue!
|
160
168
|
end
|
161
169
|
|
@@ -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.
|
@@ -17,19 +17,21 @@
|
|
17
17
|
#
|
18
18
|
module Optimizely
|
19
19
|
class Decision
|
20
|
-
attr_reader :campaign_id, :experiment_id, :variation_id
|
20
|
+
attr_reader :campaign_id, :experiment_id, :variation_id, :metadata
|
21
21
|
|
22
|
-
def initialize(campaign_id:, experiment_id:, variation_id:)
|
22
|
+
def initialize(campaign_id:, experiment_id:, variation_id:, metadata:)
|
23
23
|
@campaign_id = campaign_id
|
24
24
|
@experiment_id = experiment_id
|
25
25
|
@variation_id = variation_id
|
26
|
+
@metadata = metadata
|
26
27
|
end
|
27
28
|
|
28
29
|
def as_json
|
29
30
|
{
|
30
31
|
campaign_id: @campaign_id,
|
31
32
|
experiment_id: @experiment_id,
|
32
|
-
variation_id: @variation_id
|
33
|
+
variation_id: @variation_id,
|
34
|
+
metadata: @metadata
|
33
35
|
}
|
34
36
|
end
|
35
37
|
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,7 +19,7 @@ require_relative 'user_event'
|
|
19
19
|
require 'optimizely/helpers/date_time_utils'
|
20
20
|
module Optimizely
|
21
21
|
class ImpressionEvent < UserEvent
|
22
|
-
attr_reader :user_id, :experiment_layer_id, :experiment_id, :variation_id,
|
22
|
+
attr_reader :user_id, :experiment_layer_id, :experiment_id, :variation_id, :metadata,
|
23
23
|
:visitor_attributes, :bot_filtering
|
24
24
|
|
25
25
|
def initialize(
|
@@ -28,6 +28,7 @@ module Optimizely
|
|
28
28
|
experiment_layer_id:,
|
29
29
|
experiment_id:,
|
30
30
|
variation_id:,
|
31
|
+
metadata:,
|
31
32
|
visitor_attributes:,
|
32
33
|
bot_filtering:
|
33
34
|
)
|
@@ -38,6 +39,7 @@ module Optimizely
|
|
38
39
|
@experiment_layer_id = experiment_layer_id
|
39
40
|
@experiment_id = experiment_id
|
40
41
|
@variation_id = variation_id
|
42
|
+
@metadata = metadata
|
41
43
|
@visitor_attributes = visitor_attributes
|
42
44
|
@bot_filtering = bot_filtering
|
43
45
|
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.
|
@@ -101,10 +101,11 @@ module Optimizely
|
|
101
101
|
private
|
102
102
|
|
103
103
|
def create_impression_event_visitor(impression_event)
|
104
|
-
decision =
|
104
|
+
decision = Decision.new(
|
105
105
|
campaign_id: impression_event.experiment_layer_id,
|
106
106
|
experiment_id: impression_event.experiment_id,
|
107
|
-
variation_id: impression_event.variation_id
|
107
|
+
variation_id: impression_event.variation_id,
|
108
|
+
metadata: impression_event.metadata
|
108
109
|
)
|
109
110
|
|
110
111
|
snapshot_event = Optimizely::SnapshotEvent.new(
|
@@ -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.
|
@@ -22,7 +22,7 @@ require_relative 'event_factory'
|
|
22
22
|
module Optimizely
|
23
23
|
class UserEventFactory
|
24
24
|
# UserEventFactory builds ImpressionEvent and ConversionEvent objects from a given user_event.
|
25
|
-
def self.create_impression_event(project_config, experiment, variation_id, user_id, user_attributes)
|
25
|
+
def self.create_impression_event(project_config, experiment, variation_id, metadata, user_id, user_attributes)
|
26
26
|
# Create impression Event to be sent to the logging endpoint.
|
27
27
|
#
|
28
28
|
# project_config - Instance of ProjectConfig
|
@@ -42,13 +42,14 @@ module Optimizely
|
|
42
42
|
).as_json
|
43
43
|
|
44
44
|
visitor_attributes = Optimizely::EventFactory.build_attribute_list(user_attributes, project_config)
|
45
|
-
experiment_layer_id =
|
45
|
+
experiment_layer_id = experiment['layerId']
|
46
46
|
Optimizely::ImpressionEvent.new(
|
47
47
|
event_context: event_context,
|
48
48
|
user_id: user_id,
|
49
49
|
experiment_layer_id: experiment_layer_id,
|
50
50
|
experiment_id: experiment['id'],
|
51
51
|
variation_id: variation_id,
|
52
|
+
metadata: metadata,
|
52
53
|
visitor_attributes: visitor_attributes,
|
53
54
|
bot_filtering: project_config.bot_filtering
|
54
55
|
)
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
#
|
4
|
-
# Copyright 2016-2017, 2019
|
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.
|
@@ -16,8 +16,7 @@
|
|
16
16
|
# limitations under the License.
|
17
17
|
#
|
18
18
|
require_relative 'exceptions'
|
19
|
-
|
20
|
-
require 'httparty'
|
19
|
+
require_relative 'helpers/http_utils'
|
21
20
|
|
22
21
|
module Optimizely
|
23
22
|
class NoOpEventDispatcher
|
@@ -30,28 +29,23 @@ module Optimizely
|
|
30
29
|
# @api constants
|
31
30
|
REQUEST_TIMEOUT = 10
|
32
31
|
|
33
|
-
def initialize(logger: nil, error_handler: nil)
|
32
|
+
def initialize(logger: nil, error_handler: nil, proxy_config: nil)
|
34
33
|
@logger = logger || NoOpLogger.new
|
35
34
|
@error_handler = error_handler || NoOpErrorHandler.new
|
35
|
+
@proxy_config = proxy_config
|
36
36
|
end
|
37
37
|
|
38
38
|
# Dispatch the event being represented by the Event object.
|
39
39
|
#
|
40
40
|
# @param event - Event object
|
41
41
|
def dispatch_event(event)
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
elsif event.http_verb == :post
|
46
|
-
response = HTTParty.post(event.url,
|
47
|
-
body: event.params.to_json,
|
48
|
-
headers: event.headers,
|
49
|
-
timeout: REQUEST_TIMEOUT)
|
50
|
-
end
|
42
|
+
response = Helpers::HttpUtils.make_request(
|
43
|
+
event.url, event.http_verb, event.params.to_json, event.headers, REQUEST_TIMEOUT, @proxy_config
|
44
|
+
)
|
51
45
|
|
52
46
|
error_msg = "Event failed to dispatch with response code: #{response.code}"
|
53
47
|
|
54
|
-
case response.code
|
48
|
+
case response.code.to_i
|
55
49
|
when 400...500
|
56
50
|
@logger.log(Logger::ERROR, error_msg)
|
57
51
|
@error_handler.handle_error(HTTPCallError.new("HTTP Client Error: #{response.code}"))
|