kameleoon-client-ruby 3.22.0 → 3.22.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f492badef67f0b4e09561d3a3d423e4497dbd7efe22d07641cac9485fe9e100
4
- data.tar.gz: 95f7268b78ecd64bd4707c8fc8581251b8f07038a32eb044aebdb0b1cab652b6
3
+ metadata.gz: 7eceff94d1ab7a39dfa3603c7cfd124bad0c49a7315feb7e0bd938c45feca9f0
4
+ data.tar.gz: f3df43034ca7460e3fe21a47caa920e9ede1e65ac49cb5c04b0196198d4a2391
5
5
  SHA512:
6
- metadata.gz: 194b5aeee439890f068abe5b0b4c0f11fb508844de81e54e5b5582cefed5c7849d17054cb469994b57244177b61b9cfb89ccfecf771d9c135cc7126a277140e8
7
- data.tar.gz: 114ecb570390aa32386abf7074463d903884d6faadf8c7b3de77953bc38152dc70cdf3eb7d5d8ac64de127014f318a2d2e53359c830cb9af5d8f3118d581b8d7
6
+ metadata.gz: 7ed0b76309075aac725c827733a454754f6183001a600582f79e116c8774d5ac4e8448dbb7b58fcb006649d1801cc0d86ef0484b0e6ee70201d4eb6cf3aa92fd
7
+ data.tar.gz: 927ca5a73f64293a53b3f4e0083112ed0257de431c04fa80541102cd1fec954d80c122c9138448264090e017d78b02a8b1028ac4ed36d1e1ecb0b489b7822211
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
3
4
  require 'kameleoon/logging/kameleoon_logger'
4
5
 
5
6
  module Kameleoon
@@ -17,7 +17,7 @@ module Kameleoon
17
17
  end
18
18
  end
19
19
 
20
- # Implementation of Cache with auto cleaning feature
20
+ # Implementation of Manager with auto-expiring visitor variations
21
21
  class ManagerImpl < Manager
22
22
  def initialize(expiration_time, data_manager)
23
23
  super()
@@ -30,7 +30,6 @@ require 'kameleoon/network/network_manager'
30
30
  require 'kameleoon/network/url_provider'
31
31
  require 'kameleoon/network/cookie/cookie_manager'
32
32
  require 'kameleoon/real_time/real_time_event_service'
33
- require 'kameleoon/storage/cache_factory'
34
33
  require 'kameleoon/targeting/models'
35
34
  require 'kameleoon/targeting/targeting_manager'
36
35
  require 'kameleoon/types/variable'
@@ -1034,6 +1033,7 @@ module Kameleoon
1034
1033
  @visitor_manager.stop
1035
1034
  @tracking_manager.stop
1036
1035
  @scheduler.shutdown
1036
+ @network_manager.close
1037
1037
  Logging::KameleoonLogger.debug('RETURN: KameleoonClient.dispose')
1038
1038
  end
1039
1039
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent/executor/thread_pool_executor'
3
4
  require 'kameleoon/data/data'
4
5
  require 'kameleoon/logging/kameleoon_logger'
5
6
  require 'kameleoon/managers/tracking/tracking_builder'
@@ -11,18 +12,28 @@ module Kameleoon
11
12
  class TrackingManager
12
13
  LINES_DELIMETER = "\n"
13
14
  REQUEST_SIZE_LIMIT = 2560 * 1024 # 2.5 * 1024^2 characters
15
+ PROBE_MAX_VISITORS = 1
16
+ MAX_SEND_THREADS = 8
17
+ MAX_QUEUED_REQUESTS = 32
18
+ SEND_THREAD_IDLE_SECONDS = 60
19
+ STOP_WAIT_SECONDS = 1
14
20
 
15
21
  def initialize(
16
- data_manager, network_manager, visitor_manager, track_interval_seconds, scheduler
22
+ data_manager, network_manager, visitor_manager, track_interval_seconds, scheduler, executor: nil
17
23
  )
18
24
  Logging::KameleoonLogger.debug(
19
25
  'CALL: TrackingManager.new(data_manager, network_manager. visitor_manager, ' \
20
26
  'track_interval_seconds: %s, scheduler)', track_interval_seconds
21
27
  )
22
- @tracking_visitors = LockVisitorTrackingRegistry.new(visitor_manager)
28
+ @tracking_visitors = ConcurrentVisitorTrackingRegistry.new(visitor_manager)
29
+ @probe_mode = false
23
30
  @data_manager = data_manager
24
31
  @network_manager = network_manager
25
32
  @visitor_manager = visitor_manager
33
+ @executor = executor || Concurrent::ThreadPoolExecutor.new(
34
+ name: 'kameleoon-tracking', min_threads: 0, max_threads: MAX_SEND_THREADS,
35
+ max_queue: MAX_QUEUED_REQUESTS, idletime: SEND_THREAD_IDLE_SECONDS, fallback_policy: :abort
36
+ )
26
37
  @track_all_job = scheduler.schedule_every(track_interval_seconds, method(:track_all)) unless scheduler.nil?
27
38
  Logging::KameleoonLogger.debug(
28
39
  'RETURN: TrackingManager.new(data_manager, network_manager. visitor_manager, ' \
@@ -34,6 +45,10 @@ module Kameleoon
34
45
  Logging::KameleoonLogger.debug('CALL: TrackingManager.stop')
35
46
  @track_all_job&.unschedule
36
47
  @track_all_job = nil
48
+ # Queued requests still run; a later `track` is rejected and keeps its data (see `perform_tracking_request`).
49
+ # The wait lets in-flight requests finish before the client disposal closes the connections.
50
+ @executor.shutdown
51
+ @executor.wait_for_termination(STOP_WAIT_SECONDS)
37
52
  Logging::KameleoonLogger.debug('RETURN: TrackingManager.stop')
38
53
  end
39
54
 
@@ -45,10 +60,19 @@ module Kameleoon
45
60
 
46
61
  def track_all
47
62
  Logging::KameleoonLogger.debug('CALL: TrackingManager.track_all')
48
- track(@tracking_visitors.extract)
63
+ if @probe_mode
64
+ track_probe
65
+ else
66
+ track(@tracking_visitors.extract)
67
+ end
49
68
  Logging::KameleoonLogger.debug('RETURN: TrackingManager.track_all')
50
69
  end
51
70
 
71
+ # Test purpose only
72
+ def probe_mode?
73
+ @probe_mode
74
+ end
75
+
52
76
  def track_visitor(visitor_code)
53
77
  Logging::KameleoonLogger.debug("CALL: TrackingManager.track_visitor(visitor_code: '%s')", visitor_code)
54
78
  track([visitor_code])
@@ -57,6 +81,14 @@ module Kameleoon
57
81
 
58
82
  private
59
83
 
84
+ # Sends one visitor's data; visitors without data are dropped (as on the normal path) until one is found.
85
+ def track_probe
86
+ until (visitor_code = @tracking_visitors.extract(PROBE_MAX_VISITORS)).empty?
87
+ return if track(visitor_code)
88
+ end
89
+ end
90
+
91
+ # Returns whether a request was sent.
60
92
  def track(visitor_codes)
61
93
  builder = TrackingBuilder.new(visitor_codes, @data_manager.data_file, @visitor_manager, REQUEST_SIZE_LIMIT)
62
94
  builder.build
@@ -71,26 +103,62 @@ module Kameleoon
71
103
  perform_tracking_request(builder.visitor_codes_to_send, builder.unsent_visitor_data, builder.tracking_lines)
72
104
  end
73
105
 
106
+ # Returns whether a request was sent.
74
107
  def perform_tracking_request(visitor_codes, visitor_data, tracking_lines)
75
- return if visitor_data.empty?
108
+ return false if visitor_data.empty?
76
109
 
77
110
  lines = tracking_lines.join(LINES_DELIMETER)
78
111
  # mark unsent data as transmitted
79
112
  visitor_data.each { |d| d.mark_as_transmitting if d.is_a?(Kameleoon::Data) }
80
- Thread.new do
81
- result = @network_manager.send_tracking_data(lines)
82
- if result != false
83
- Logging::KameleoonLogger.info('Successful request for tracking visitors: %s, data: %s',
84
- visitor_codes, visitor_data)
85
- visitor_data.each { |d| d.mark_as_sent if d.is_a?(Kameleoon::Data) }
86
- else
87
- Logging::KameleoonLogger.error('Tracking request failed')
88
- Logging::KameleoonLogger.info('Failed request for tracking visitors: %s, data: %s',
89
- visitor_codes, visitor_data)
90
- visitor_data.each { |d| d.mark_as_unsent if d.is_a?(Kameleoon::Data) }
91
- @tracking_visitors.add_all(visitor_codes)
92
- end
113
+ begin
114
+ @executor.post { send_tracking_request(visitor_codes, visitor_data, lines) }
115
+ rescue Concurrent::RejectedExecutionError
116
+ Logging::KameleoonLogger.warning(
117
+ 'Tracking request pool is full or stopped: data of %s visitors is kept to be sent later',
118
+ visitor_codes.size
119
+ )
120
+ keep_for_later(visitor_codes, visitor_data)
121
+ end
122
+ true
123
+ end
124
+
125
+ def send_tracking_request(visitor_codes, visitor_data, lines)
126
+ result = @network_manager.send_tracking_data(lines)
127
+ if result != false
128
+ Logging::KameleoonLogger.info('Successful request for tracking visitors: %s, data: %s',
129
+ visitor_codes, visitor_data)
130
+ visitor_data.each { |d| d.mark_as_sent if d.is_a?(Kameleoon::Data) }
131
+ leave_probe_mode
132
+ else
133
+ Logging::KameleoonLogger.error('Tracking request failed')
134
+ Logging::KameleoonLogger.info('Failed request for tracking visitors: %s, data: %s',
135
+ visitor_codes, visitor_data)
136
+ enter_probe_mode
137
+ keep_for_later(visitor_codes, visitor_data)
93
138
  end
139
+ rescue StandardError => e
140
+ Logging::KameleoonLogger.error('Error occurred within tracking request: %s', e)
141
+ keep_for_later(visitor_codes, visitor_data)
142
+ end
143
+
144
+ def keep_for_later(visitor_codes, visitor_data)
145
+ visitor_data.each { |d| d.mark_as_unsent if d.is_a?(Kameleoon::Data) }
146
+ @tracking_visitors.add_all(visitor_codes)
147
+ end
148
+
149
+ def enter_probe_mode
150
+ return if @probe_mode
151
+
152
+ @probe_mode = true
153
+ Logging::KameleoonLogger.info("Tracking request failed: only one visitor's data per tracking request " \
154
+ 'will be sent until a request succeeds')
155
+ end
156
+
157
+ def leave_probe_mode
158
+ return unless @probe_mode
159
+
160
+ @probe_mode = false
161
+ Logging::KameleoonLogger.info('Tracking request succeeded: full-size tracking requests are restored')
94
162
  end
95
163
  end
96
164
  end
@@ -1,91 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  module Kameleoon
6
4
  module Managers
7
5
  module Tracking
8
- class LockVisitorTrackingRegistry
6
+ # Visitor codes waiting for a tracking request, in a Hash used as an insertion-ordered set.
7
+ #
8
+ # Lock-free on the hot path (MRI): `add` is a single Hash#[]=, which the GVL makes atomic (String keys hash
9
+ # in C, so no Ruby code runs inside it), and request threads never block on the registry. The Hash is never
10
+ # swapped out; `extract` removes with Hash#shift (O(1), oldest first) under a lock that only serializes
11
+ # extractors, so a concurrently added code is either returned now or kept for the next extraction, never
12
+ # lost. Nothing here iterates the live Hash: an `add` during iteration would raise in the request thread.
13
+ # Other engines run threads in parallel and take the lock on `add` as well: JRuby's Hash raises
14
+ # ConcurrencyError on unsynchronized concurrent writes; TruffleRuby's shared collections are thread-safe,
15
+ # but the lock-free path has not been measured there.
16
+ class ConcurrentVisitorTrackingRegistry
9
17
  LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT = 2
10
18
  REMOVAL_FACTOR = 0.8
19
+ UNLIMITED_EXTRACTION = 2**62
20
+ LOCK_FREE_ADD = (RUBY_ENGINE == 'ruby')
11
21
 
12
22
  def initialize(visitor_manager, storage_limit = 1_000_000, extraction_limit = 20_000)
13
23
  @visitor_manager = visitor_manager
14
24
  @storage_limit = storage_limit
15
25
  @extraction_limit = extraction_limit
16
- @visitors = Set.new
17
- @mutex = Mutex.new
26
+ @visitors = {}
27
+ @extract_mutex = Mutex.new
18
28
  end
19
29
 
20
- def add(visitor_code)
21
- @mutex.synchronize { @visitors.add(visitor_code) }
22
- end
30
+ if LOCK_FREE_ADD
31
+ def add(visitor_code)
32
+ @visitors[visitor_code] = true
33
+ end
23
34
 
24
- def add_all(visitor_codes)
25
- @mutex.synchronize do
26
- @visitors.merge(visitor_codes)
27
- if @visitors.size > @storage_limit
28
- erase_nonexistent_visitors
29
- erase_to_storage_limit
30
- end
35
+ def add_all(visitor_codes)
36
+ visitor_codes.each { |vc| @visitors[vc] = true }
37
+ erase_to_storage_limit if @visitors.size > @storage_limit
38
+ end
39
+ else
40
+ def add(visitor_code)
41
+ @extract_mutex.synchronize { @visitors[visitor_code] = true }
31
42
  end
43
+
44
+ def add_all(visitor_codes)
45
+ @extract_mutex.synchronize { visitor_codes.each { |vc| @visitors[vc] = true } }
46
+ erase_to_storage_limit if @visitors.size > @storage_limit # takes the (non-reentrant) mutex itself
47
+ end
48
+ end
49
+
50
+ # Removes and returns visitor codes, oldest first: all of them while the registry holds no more than
51
+ # `limit` codes and fewer than twice the extraction limit, otherwise `min(limit, extraction_limit)`.
52
+ def extract(limit = UNLIMITED_EXTRACTION)
53
+ @extract_mutex.synchronize { shift_visitors(extraction_count(@visitors.size, limit)) }
32
54
  end
33
55
 
34
- def extract
35
- should_extract_all_be_used ? extract_all : extract_limited
56
+ # Test purpose only
57
+ def visitors
58
+ @visitors.keys
36
59
  end
37
60
 
38
61
  private
39
62
 
40
- def should_extract_all_be_used
41
- @visitors.size < @extraction_limit * LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT
42
- end
63
+ def extraction_count(size, limit)
64
+ return size if (size <= limit) && (size < @extraction_limit * LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT)
43
65
 
44
- def extract_all
45
- old_visitors = nil
46
- new_visitors = Set.new
47
- @mutex.synchronize do
48
- old_visitors = @visitors
49
- @visitors = new_visitors
50
- end
51
- old_visitors
66
+ [limit, @extraction_limit].min
52
67
  end
53
68
 
54
- def extract_limited
55
- extracted = nil
56
- @mutex.synchronize do
57
- if should_extract_all_be_used
58
- extracted = extract_all
59
- next
60
- end
61
- i = 0
62
- extracted = []
63
- @visitors.each do |vc|
64
- break if i >= @extraction_limit
69
+ # Fewer than `count` codes come back only if the registry runs empty meanwhile.
70
+ def shift_visitors(count)
71
+ extracted = []
72
+ while extracted.size < count
73
+ pair = @visitors.shift
74
+ break if pair.nil?
65
75
 
66
- extracted.push(vc)
67
- i += 1
68
- @visitors.delete(vc)
69
- end
76
+ extracted.push(pair[0])
70
77
  end
71
78
  extracted
72
79
  end
73
80
 
74
- # Not thread-safe
75
- def erase_nonexistent_visitors
76
- @visitors.delete_if { |vc| @visitor_manager.get_visitor(vc).nil? }
77
- end
78
-
79
- # Not thread-safe
80
81
  def erase_to_storage_limit
81
- visitors_to_remove_count = @visitors.size - (@storage_limit * REMOVAL_FACTOR).to_i
82
- return if visitors_to_remove_count <= 0
83
-
84
- @visitors.each do |vc|
85
- break if visitors_to_remove_count.zero?
86
-
87
- visitors_to_remove_count -= 1
88
- @visitors.delete(vc)
82
+ @extract_mutex.synchronize do
83
+ # Drain and re-insert instead of `delete_if`, so that concurrent adds never hit an iteration in progress.
84
+ # Codes added while draining stay in the live Hash.
85
+ drained = shift_visitors(@visitors.size)
86
+ drained.each { |vc| @visitors[vc] = true unless @visitor_manager.peek_visitor(vc).nil? }
87
+ visitors_to_remove_count = @visitors.size - (@storage_limit * REMOVAL_FACTOR).to_i
88
+ visitors_to_remove_count.times { @visitors.shift }
89
89
  end
90
90
  end
91
91
  end
@@ -6,10 +6,12 @@ require 'time'
6
6
  require 'kameleoon/utils'
7
7
  require 'kameleoon/logging/kameleoon_logger'
8
8
  require 'base64'
9
+ require 'concurrent'
9
10
 
10
11
  module Kameleoon
11
12
  module Network
12
13
  class AccessTokenSource
14
+ SILENCE_PERIOD = 300 # in seconds
13
15
  TOKEN_EXPIRATION_GAP = 60 # in seconds
14
16
  TOKEN_OBSOLESCENCE_GAP = 1800 # in seconds
15
17
  JWT_ACCESS_TOKEN_FIELD = 'access_token'
@@ -24,7 +26,10 @@ module Kameleoon
24
26
  @network_manager = network_manager
25
27
  @client_id = client_id
26
28
  @client_secret = client_secret
27
- @fetching = false
29
+ @cached_token = nil
30
+ @silent_after_fetch_failure_until = 0.0 # 0 = never silenced
31
+ @fetching = nil # Concurrent::Event of the in-flight fetch, shared by all concurrent callers; nil when idle
32
+ @fetching_mutex = Mutex.new
28
33
  @basic_auth_token = AccessTokenSource.construct_basic_token(client_id, client_secret)
29
34
  Logging::KameleoonLogger.debug(lambda {
30
35
  format("RETURN: AccessTokenSource.new(network_manager, client_id: '%s', client_secret: '%s')",
@@ -37,16 +42,24 @@ module Kameleoon
37
42
  "#{BASIC_AUTHORIZATION_PREFIX}#{Base64.strict_encode64(basic_token_content)}"
38
43
  end
39
44
 
45
+ # Returns the access token, or `nil` if it cannot be obtained. Fetches a new token if the cached one is
46
+ # expired or obsolete, unless a recent fetch failure has put the source in the silence period.
40
47
  def get_token(timeout = nil)
41
- Logging::KameleoonLogger.debug('CALL: AccessTokenSource.getToken(timeout: %s)', timeout)
42
- now = Time.new.to_f
48
+ Logging::KameleoonLogger.debug('CALL: AccessTokenSource.get_token(timeout: %s)', timeout)
49
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
43
50
  token = @cached_token
44
- return call_fetch_token(timeout) if token.nil? || token.expired?(now)
45
-
46
- run_fetch_token if !@fetching && token.obsolete?(now)
47
- Logging::KameleoonLogger.debug("RETURN: AccessTokenSource.getToken(timeout: %s) -> (token: '%s')",
48
- timeout, token.value)
49
- token.value
51
+ silent = now < @silent_after_fetch_failure_until
52
+ if !token.nil? && !token.expired?(now)
53
+ refresh_token_in_background(timeout) if token.obsolete?(now) && !silent
54
+ value = token.value
55
+ elsif silent
56
+ value = nil
57
+ else
58
+ value = fetch_token(timeout)
59
+ end
60
+ Logging::KameleoonLogger.debug("RETURN: AccessTokenSource.get_token(timeout: %s) -> (token: '%s')",
61
+ timeout, value)
62
+ value
50
63
  end
51
64
 
52
65
  def discard_token(token)
@@ -58,54 +71,114 @@ module Kameleoon
58
71
 
59
72
  private
60
73
 
61
- def call_fetch_token(timeout)
62
- @fetching = true
63
- fetch_token(timeout)
64
- rescue StandardError => e
65
- @fetching = false
66
- Logging::KameleoonLogger.error("Failed to call access token fetching: #{e}")
67
- nil
68
- end
69
-
70
- def run_fetch_token
71
- # setting `@fetching` here to reduce the number of requests until new thread started
72
- @fetching = true
73
- Thread.new { fetch_token }
74
- rescue StandardError => e
75
- @fetching = false
76
- Logging::KameleoonLogger.error("Failed to run access token fetching: #{e}")
77
- end
78
-
79
- def fetch_token(timeout = nil)
80
- Logging::KameleoonLogger.debug('CALL: AccessTokenSource.fetch_token(timeout: %s)', timeout)
81
- response_content = @network_manager.fetch_access_jwtoken(@basic_auth_token, timeout)
82
- unless response_content
83
- Logging::KameleoonLogger.error('Failed to fetch access JWT')
84
- return nil
74
+ NO_FETCH = [nil, false].freeze
75
+
76
+ # The first caller performs the request; concurrent callers wait for it - no longer than their own request
77
+ # timeout - and then read the cached token.
78
+ def fetch_token(timeout)
79
+ fetching, owner = claim_fetch(false)
80
+ if fetching.nil?
81
+ cached_token_value
82
+ elsif owner
83
+ perform_fetch(fetching, timeout)
84
+ else
85
+ unless fetching.wait(timeout.nil? ? nil : timeout / 1000.0)
86
+ Logging::KameleoonLogger.debug('Access token fetch has not completed within %s ms', timeout)
87
+ end
88
+ cached_token_value
85
89
  end
90
+ end
91
+
92
+ # Refreshes a valid but obsolete token while it keeps being served.
93
+ def refresh_token_in_background(timeout)
94
+ fetching, owner = claim_fetch(true)
95
+ Thread.new { perform_fetch(fetching, timeout) } if owner
96
+ rescue StandardError => e # e.g. ThreadError: the thread limit is reached
97
+ enter_silence(e)
98
+ complete_fetch(fetching)
99
+ end
100
+
101
+ # Returns `[event, owner]`: the event of the in-flight fetch and whether the caller has just registered it
102
+ # (and so must perform it); NO_FETCH when no request is needed anymore.
103
+ def claim_fetch(refresh)
104
+ @fetching_mutex.synchronize do
105
+ return [@fetching, false] unless @fetching.nil?
106
+ # get_token decided without the mutex; meanwhile another caller's fetch may have cached a token, or failed
107
+ # and started the silence period.
108
+ return NO_FETCH unless fetch_needed?(refresh)
109
+
110
+ @fetching = Concurrent::Event.new
111
+ [@fetching, true]
112
+ end
113
+ end
114
+
115
+ # `refresh`: the caller holds a valid but obsolete token, which is worth a request only while still obsolete.
116
+ def fetch_needed?(refresh)
117
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
118
+ return false if now < @silent_after_fetch_failure_until
119
+
120
+ token = @cached_token
121
+ token.nil? || token.expired?(now) || (refresh && token.obsolete?(now))
122
+ end
123
+
124
+ def cached_token_value
125
+ token = @cached_token
126
+ token.nil? || token.expired?(Process.clock_gettime(Process::CLOCK_REALTIME)) ? nil : token.value
127
+ end
128
+
129
+ def perform_fetch(fetching, timeout)
130
+ Logging::KameleoonLogger.debug('CALL: AccessTokenSource.perform_fetch(timeout: %s)', timeout)
131
+ token = nil
86
132
  begin
133
+ response_content = @network_manager.fetch_access_jwtoken(@basic_auth_token, timeout)
134
+ raise FetchError, 'access token request failed' unless response_content
135
+
87
136
  jwt = JSON.parse(response_content)
88
- token = jwt[JWT_ACCESS_TOKEN_FIELD]
89
- expires_in = jwt[JWT_EXPIRES_IN_FIELD]
90
- rescue JSON::ParserError => e
91
- Logging::KameleoonLogger.error("Failed to parse access JWT: #{e}")
92
- return nil
93
- end
94
- unless token.is_a?(String) && !token.empty? && expires_in.is_a?(Integer) && expires_in.positive?
95
- Logging::KameleoonLogger.error('Failed to read access JWT')
96
- return nil
137
+ raise FetchError, "access token response is not a JSON object: '#{jwt}'" unless jwt.is_a?(Hash)
138
+
139
+ expires_in = read_expires_in(jwt)
140
+ token = read_access_token(jwt)
141
+ @cached_token = new_expiring_token(token, expires_in)
142
+ Logging::KameleoonLogger.info('Fetched access token')
143
+ rescue StandardError => e
144
+ token = nil
145
+ enter_silence(e)
146
+ ensure
147
+ complete_fetch(fetching) # also on non-StandardError, so that waiters are never stuck
97
148
  end
98
- token = handle_fetched_token(token, expires_in)
99
- Logging::KameleoonLogger.debug(
100
- "RETURN: AccessTokenSource.fetch_token(timeout: %s) -> (token: '%s')", timeout, token
101
- )
149
+ Logging::KameleoonLogger.debug("RETURN: AccessTokenSource.perform_fetch(timeout: %s) -> (token: '%s')",
150
+ timeout, token)
102
151
  token
103
- ensure
104
- @fetching = false
105
152
  end
106
153
 
107
- def handle_fetched_token(token, expires_in)
108
- now = Time.new.to_f
154
+ def enter_silence(error)
155
+ @silent_after_fetch_failure_until = Process.clock_gettime(Process::CLOCK_REALTIME) + SILENCE_PERIOD
156
+ Logging::KameleoonLogger.warning('Failed to fetch access token (%s: %s); it will not be requested for %ds',
157
+ error.class, error.message, SILENCE_PERIOD)
158
+ end
159
+
160
+ def complete_fetch(fetching)
161
+ # Clear before waking the waiters so that a waiter re-entering get_token can start a new fetch.
162
+ @fetching_mutex.synchronize { @fetching = nil if @fetching.equal?(fetching) }
163
+ fetching.set
164
+ end
165
+
166
+ def read_expires_in(jwt)
167
+ expires_in = jwt[JWT_EXPIRES_IN_FIELD]
168
+ return expires_in if expires_in.is_a?(Integer) && expires_in.positive?
169
+
170
+ raise FetchError, "access token response has no valid '#{JWT_EXPIRES_IN_FIELD}' field: '#{expires_in}'"
171
+ end
172
+
173
+ def read_access_token(jwt)
174
+ token = jwt[JWT_ACCESS_TOKEN_FIELD]
175
+ return token if token.is_a?(String) && !token.empty?
176
+
177
+ raise FetchError, "access token response has no valid '#{JWT_ACCESS_TOKEN_FIELD}' field"
178
+ end
179
+
180
+ def new_expiring_token(token, expires_in)
181
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
109
182
  exp_time = now + expires_in - TOKEN_EXPIRATION_GAP
110
183
  if expires_in > TOKEN_OBSOLESCENCE_GAP
111
184
  obs_time = now + expires_in - TOKEN_OBSOLESCENCE_GAP
@@ -121,9 +194,10 @@ module Kameleoon
121
194
  )
122
195
  end
123
196
  end
124
- @cached_token = ExpiringToken.new(token, exp_time, obs_time)
125
- token
197
+ ExpiringToken.new(token, exp_time, obs_time)
126
198
  end
199
+
200
+ class FetchError < StandardError; end
127
201
  end
128
202
 
129
203
  class ExpiringToken
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'net/http'
4
+ require 'net/http/persistent'
4
5
  require 'kameleoon/version'
5
6
  require 'kameleoon/network/response'
7
+ require 'kameleoon/logging/kameleoon_logger'
6
8
  require 'kameleoon/exceptions'
7
9
 
8
10
  module Kameleoon
@@ -12,6 +14,9 @@ module Kameleoon
12
14
  raise KameleoonError, 'Call of not implemented method!'
13
15
  end
14
16
 
17
+ # Releases the resources held by the provider (e.g. kept-alive connections); no-op by default.
18
+ def close; end
19
+
15
20
  private
16
21
 
17
22
  def collect_headers(request)
@@ -29,7 +34,27 @@ module Kameleoon
29
34
  end
30
35
  end
31
36
 
37
+ # Performs requests over kept-alive connections managed by `Net::HTTP::Persistent`.
38
+ #
39
+ # `Net::HTTP::Persistent` timeouts are per client, while the SDK timeout is per request, so one client (with its
40
+ # own connection pool) is kept per distinct timeout value. In practice that is the default timeout plus the few
41
+ # values callers pass explicitly.
32
42
  class SyncNetProvider < NetProvider
43
+ # Idle connections are reconnected before use after this long; keeps a request from hitting a connection the
44
+ # server has already dropped.
45
+ IDLE_TIMEOUT = 15 # seconds
46
+ # Guards against a caller passing a different timeout on every request (each value would keep its own idle
47
+ # connections). Requests with further timeout values are sent on a one-off connection with their own timeout.
48
+ MAX_CLIENTS = 16
49
+
50
+ def initialize
51
+ super()
52
+ @clients = {}
53
+ @clients_mutex = Mutex.new
54
+ @overflow_warned = false
55
+ @closed = false
56
+ end
57
+
33
58
  def make_request(request)
34
59
  for _ in 0..8 do
35
60
  response = make_single_request(request)
@@ -44,10 +69,17 @@ module Kameleoon
44
69
  response
45
70
  end
46
71
 
72
+ def close
73
+ @clients_mutex.synchronize do
74
+ @closed = true
75
+ @clients.each_value(&:shutdown)
76
+ @clients.clear
77
+ end
78
+ end
79
+
47
80
  private
48
81
 
49
82
  def make_single_request(request)
50
- resp = nil
51
83
  case request.method
52
84
  when Method::GET
53
85
  req = Net::HTTP::Get.new(request.url)
@@ -57,21 +89,71 @@ module Kameleoon
57
89
  else
58
90
  return unknown_method_response(request.method, request)
59
91
  end
60
- timeout = request.timeout.to_f / 1000.0
61
- headers = collect_headers(request)
62
- headers.each { |k, v| req[k] = v }
92
+ collect_headers(request).each { |k, v| req[k] = v }
63
93
  uri = URI(request.url)
64
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: timeout,
65
- read_timeout: timeout, ssl_timeout: timeout) do |http|
66
- resp = http.request(req)
67
- end
94
+ timeout = request.timeout.to_f / 1000.0
95
+ client = client_for(timeout)
96
+ resp = client ? client.request(uri, req) : request_once(uri, req, timeout)
68
97
  body = resp.body
69
98
  body = nil if body&.empty?
70
- headers = resp.to_hash
71
- Response.new(nil, resp.code.to_i, body, headers, request)
99
+ Response.new(nil, resp.code.to_i, body, resp.to_hash, request)
72
100
  rescue StandardError => e
73
101
  Response.new(e, nil, nil, nil, request)
74
102
  end
103
+
104
+ def client_for(timeout)
105
+ @clients_mutex.synchronize do
106
+ return nil if @closed
107
+
108
+ client = @clients[timeout]
109
+ return client if client
110
+
111
+ if @clients.size >= MAX_CLIENTS
112
+ unless @overflow_warned
113
+ @overflow_warned = true
114
+ Logging::KameleoonLogger.warning(
115
+ 'More than %s distinct request timeouts in use; requests with further timeout values ' \
116
+ 'are sent without connection reuse', MAX_CLIENTS
117
+ )
118
+ end
119
+ return nil
120
+ end
121
+
122
+ @clients[timeout] = build_client(timeout)
123
+ end
124
+ end
125
+
126
+ # Sends the request on a connection which is closed afterwards, honouring `timeout` (seconds).
127
+ def request_once(uri, req, timeout)
128
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', open_timeout: timeout,
129
+ read_timeout: timeout, write_timeout: timeout) do |http|
130
+ http.request(req)
131
+ end
132
+ end
133
+
134
+ # The timeout is applied as given, also when it is zero or negative (as `Net::HTTP` did before pooling):
135
+ # validating it is the callers' concern.
136
+ def build_client(timeout)
137
+ http = Net::HTTP::Persistent.new(name: "kameleoon-sdk-#{timeout}", proxy: proxy_setting)
138
+ http.idle_timeout = IDLE_TIMEOUT
139
+ http.open_timeout = timeout
140
+ http.read_timeout = timeout
141
+ http.write_timeout = timeout
142
+ http
143
+ end
144
+
145
+ # `:ENV` keeps the `http_proxy`/`no_proxy` support that plain `Net::HTTP` has by default. net-http-persistent
146
+ # 4.0.8 parses `no_proxy` with `Array#filter_map` (Ruby 2.7+), so on older Rubies with `no_proxy` set the proxy
147
+ # URI is passed directly, without the bypass list (Kameleoon hosts are not expected to be in it anyway).
148
+ def proxy_setting
149
+ no_proxy = ENV['no_proxy'] || ENV['NO_PROXY']
150
+ return :ENV if RUBY_VERSION >= '2.7' || no_proxy.nil? || no_proxy.empty?
151
+
152
+ env_proxy = ENV['http_proxy'] || ENV['HTTP_PROXY']
153
+ return nil if env_proxy.nil? || env_proxy.empty? || no_proxy == '*'
154
+
155
+ URI(env_proxy.match?(%r{\Ahttps?://}) ? env_proxy : "http://#{env_proxy}")
156
+ end
75
157
  end
76
158
  end
77
159
  end
@@ -86,6 +86,12 @@ module Kameleoon
86
86
  unwrap_response(*make_call(Events::RequestType::ACCESS_TOKEN, request, false))
87
87
  end
88
88
 
89
+ # Closes the kept-alive connections. A request still running completes on its connection; a request started
90
+ # afterwards fails with an error response.
91
+ def close
92
+ @sync_net_provider.close
93
+ end
94
+
89
95
  private
90
96
 
91
97
  def make_call(request_type, request, try_access_token_auth, retry_limit = 0, retry_delay = 0)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'restclient'
3
+ require 'net/http'
4
4
 
5
5
  module Kameleoon
6
6
  module RealTime
@@ -31,8 +31,11 @@ module Kameleoon
31
31
  ##
32
32
  # Starts SSE connection and stay in the loop until close.
33
33
  def start
34
- RestClient::Request.execute(method: :get, url: @url, headers: @headers, read_timeout: nil,
35
- block_response: method(:handle_resp))
34
+ uri = URI(@url)
35
+ # No read timeout: the stream is expected to stay silent between events.
36
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: nil) do |http|
37
+ http.request(Net::HTTP::Get.new(uri, @headers)) { |resp| handle_resp(resp) }
38
+ end
36
39
  end
37
40
 
38
41
  ##
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kameleoon
4
- SDK_VERSION = '3.22.0'
4
+ SDK_VERSION = '3.22.2'
5
5
  SDK_NAME = 'RUBY'
6
6
  end
metadata CHANGED
@@ -1,43 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kameleoon-client-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.22.0
4
+ version: 3.22.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kameleoon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-27 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: em-http-request
14
+ name: concurrent-ruby
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: 1.1.5
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: 1.1.5
27
- - !ruby/object:Gem::Dependency
28
- name: em-synchrony
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
17
+ - - "~>"
32
18
  - !ruby/object:Gem::Version
33
- version: 1.0.6
19
+ version: '1.2'
34
20
  type: :runtime
35
21
  prerelease: false
36
22
  version_requirements: !ruby/object:Gem::Requirement
37
23
  requirements:
38
- - - ">="
24
+ - - "~>"
39
25
  - !ruby/object:Gem::Version
40
- version: 1.0.6
26
+ version: '1.2'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: rufus-scheduler
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -53,19 +39,19 @@ dependencies:
53
39
  - !ruby/object:Gem::Version
54
40
  version: '3.7'
55
41
  - !ruby/object:Gem::Dependency
56
- name: rest-client
42
+ name: net-http-persistent
57
43
  requirement: !ruby/object:Gem::Requirement
58
44
  requirements:
59
45
  - - "~>"
60
46
  - !ruby/object:Gem::Version
61
- version: '2.1'
47
+ version: '4.0'
62
48
  type: :runtime
63
49
  prerelease: false
64
50
  version_requirements: !ruby/object:Gem::Requirement
65
51
  requirements:
66
52
  - - "~>"
67
53
  - !ruby/object:Gem::Version
68
- version: '2.1'
54
+ version: '4.0'
69
55
  description: Kameleoon Client Ruby Software Development Kit
70
56
  email:
71
57
  - sdk@kameleoon.com
@@ -155,8 +141,6 @@ files:
155
141
  - lib/kameleoon/real_time/sse_message.rb
156
142
  - lib/kameleoon/real_time/sse_request.rb
157
143
  - lib/kameleoon/sem_version.rb
158
- - lib/kameleoon/storage/cache.rb
159
- - lib/kameleoon/storage/cache_factory.rb
160
144
  - lib/kameleoon/targeting/condition.rb
161
145
  - lib/kameleoon/targeting/condition_factory.rb
162
146
  - lib/kameleoon/targeting/conditions/browser_condition.rb
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'concurrent'
4
-
5
- module Kameleoon
6
- module Storage
7
- # Will be useful for Ruby 3.0
8
- # Abstract Cache class (interface)
9
- class Cache
10
- def get(_key)
11
- raise 'Abstract method `read` called'
12
- end
13
-
14
- def set(_key, _value)
15
- raise 'Abstract method `write` called'
16
- end
17
-
18
- def active_items
19
- raise 'Abstract method `active_items` called'
20
- end
21
- end
22
-
23
- # Implementation of Cache with auto cleaning feature
24
- class CacheImpl < Cache
25
- def initialize(expiration_time, cleaning_interval)
26
- super()
27
- @mutex = Mutex.new
28
- @expiration_time = expiration_time
29
- @cleaning_interval = cleaning_interval
30
- @cache = {}
31
- end
32
-
33
- def set(key, value)
34
- @mutex.synchronize do
35
- start_cleaner_timer if @cleaning_interval.positive? && @cleaner_timer.nil?
36
- @cache[key] = { value: value, expired: Time.now + @expiration_time }
37
- end
38
- end
39
-
40
- def get(key)
41
- entry = @cache[key]
42
- return entry[:value] unless entry.nil? || expired?(entry)
43
-
44
- @mutex.synchronize { @cache.delete(key) }
45
- nil
46
- end
47
-
48
- def active_items
49
- active_items = {}
50
- remove_expired_entries
51
- @mutex.synchronize do
52
- @cache.each_pair { |key, entry| active_items[key] = entry[:value] }
53
- end
54
- active_items
55
- end
56
-
57
- private
58
-
59
- def start_cleaner_timer
60
- @cleaner_timer = Concurrent::TimerTask.new(execution_interval: @cleaning_interval) do
61
- remove_expired_entries
62
- end
63
- @cleaner_timer.execute
64
- end
65
-
66
- def stop_cleaner_timer
67
- @cleaner_timer&.shutdown
68
- @cleaner_timer = nil
69
- end
70
-
71
- def remove_expired_entries
72
- time = Time.now
73
- @mutex.synchronize do
74
- @cache.delete_if { |_, entry| expired?(entry, time) }
75
- stop_cleaner_timer if @cache.empty?
76
- end
77
- end
78
-
79
- def expired?(entry, time = Time.now)
80
- entry[:expired] <= time
81
- end
82
- end
83
- end
84
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'concurrent'
4
- require_relative 'cache'
5
-
6
- module Kameleoon
7
- module Storage
8
- # Will be useful for Ruby 3.0
9
- # Abstract CacheFactory class (interface)
10
- class CacheFactory
11
- def create(_experiration_time, _cleaning_time)
12
- raise 'Abstract method `create` called'
13
- end
14
- end
15
-
16
- # Implementation of CacheFactory with auto cleaning feature
17
- class CacheFactoryImpl < CacheFactory
18
- def create(expiration_time, cleaning_interval)
19
- CacheImpl.new(expiration_time, cleaning_interval)
20
- end
21
- end
22
- end
23
- end