rudder-sdk-ruby 3.1.1 → 3.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52b1b8f35eb08a1605158c6b2e4c5b2a2ee2a1be857feec7bb6dec09b5588a3f
4
- data.tar.gz: 1fc177549a89b5815d3f1b9040305b9b97b4be2dbe6b752e611a433ac45a4c45
3
+ metadata.gz: 0c9b8eb6cc946f9d16599987f88eba38e8144ceb857dc546d100c71a060c4d96
4
+ data.tar.gz: a06fe89b9b8a79b6d3a0699efe079056c71f0c2a7220b2c38941685e6856f464
5
5
  SHA512:
6
- metadata.gz: 7bfb7b5aa455e71191996319567c7e06a4e2b15133439b187fa1028991a15620697b3baa17523bffe0be5aa372263423a6048b70fac11c85592be272eedb48d7
7
- data.tar.gz: 3baffcfa0311bdcdd9cd120426f7a3f04f0c2b482bc9b57f382dad1301486d8b3c41b6ac467143a384352a88c3b005fc145ad5746e0c7da9a7a7a028e4cc0ca9
6
+ metadata.gz: 61c607dc7ca890d18b76f15caed8f7fb5ca11f6af1e3d11084eec7867c2df4ca2aa9247a64ff429899ac34b0d33d8a5e0be78cfb865a242821efb7a6456f6d66
7
+ data.tar.gz: 8bcde926a31525d8d60e6a3e534fc0806cfcb5db15dfb1da7402ffe4b44ef4bc77ac66ce19500f2200f96387382ca0da59625a38553f58617cc4a61bf4888bd2
@@ -15,36 +15,30 @@ module Rudder
15
15
  # @option opts [Numeric] :randomization_factor The randomization factor
16
16
  # to use to create a range around the retry interval
17
17
  def initialize(opts = {})
18
- @min_timeout_ms = opts[:min_timeout_ms] || MIN_TIMEOUT_MS
19
- @max_timeout_ms = opts[:max_timeout_ms] || MAX_TIMEOUT_MS
20
- @multiplier = opts[:multiplier] || MULTIPLIER
21
- @randomization_factor = opts[:randomization_factor] || RANDOMIZATION_FACTOR
18
+ @min_timeout_ms = [opts[:min_timeout_ms] || MIN_TIMEOUT_MS, 0].max
19
+ @max_timeout_ms = [opts[:max_timeout_ms] || MAX_TIMEOUT_MS, 0].max
20
+ @multiplier = [opts[:multiplier] || MULTIPLIER, 0].max
21
+ @randomization_factor = [[opts[:randomization_factor] || RANDOMIZATION_FACTOR, 0].max, 1].min
22
22
 
23
23
  @attempts = 0
24
24
  end
25
25
 
26
26
  # @return [Numeric] the next backoff interval, in milliseconds.
27
- def next_interval
28
- interval = @min_timeout_ms * (@multiplier**@attempts)
27
+ def next_interval(floor_ms = 0)
28
+ interval = [@min_timeout_ms * (@multiplier**@attempts), @max_timeout_ms].min
29
+ interval = [interval, floor_ms].max
29
30
  interval = add_jitter(interval, @randomization_factor)
30
31
 
31
32
  @attempts += 1
32
-
33
- [interval, @max_timeout_ms].min
33
+ interval
34
34
  end
35
35
 
36
36
  private
37
37
 
38
38
  def add_jitter(base, randomization_factor)
39
- random_number = rand
40
- max_deviation = base * randomization_factor
41
- deviation = random_number * max_deviation
42
-
43
- if random_number < 0.5
44
- base - deviation
45
- else
46
- base + deviation
47
- end
39
+ return base if base <= 0 || randomization_factor <= 0
40
+
41
+ base + (rand * base * randomization_factor)
48
42
  end
49
43
  end
50
44
  end
@@ -7,7 +7,9 @@ module Rudder
7
7
  class Configuration
8
8
  include Rudder::Analytics::Utils
9
9
 
10
- attr_reader :write_key, :data_plane_url, :on_error, :on_error_with_messages, :stub, :gzip, :ssl, :batch_size, :test, :max_queue_size, :backoff_policy, :retries
10
+ attr_reader :write_key, :data_plane_url, :on_error, :on_error_with_messages, :stub, :gzip, :ssl, :batch_size,
11
+ :test, :max_queue_size, :backoff_policy, :retries, :retry_base_delay, :max_retry_delay,
12
+ :retry_jitter_ratio, :respect_retry_after
11
13
 
12
14
  def initialize(settings = {})
13
15
  symbolized_settings = symbolize_keys(settings)
@@ -23,12 +25,42 @@ module Rudder
23
25
  @batch_size = symbolized_settings[:batch_size] || Defaults::MessageBatch::MAX_SIZE
24
26
  @gzip = symbolized_settings[:gzip]
25
27
  @backoff_policy = symbolized_settings[:backoff_policy]
26
- @retries = symbolized_settings[:retries]
28
+ configure_retry(symbolized_settings)
27
29
  raise ArgumentError, 'Missing required option :write_key' \
28
30
  unless @write_key
29
31
  raise ArgumentError, 'Data plane url must be initialized' \
30
32
  unless @data_plane_url
31
33
  end
34
+
35
+ private
36
+
37
+ def configure_retry(settings)
38
+ @retries = settings[:retries]
39
+ @retry_base_delay = normalize_non_negative_integer(settings[:retry_base_delay])
40
+ @max_retry_delay = normalize_non_negative_integer(
41
+ settings[:max_retry_delay] || settings[:maximum_backoff_duration]
42
+ )
43
+ @retry_jitter_ratio = normalize_jitter_ratio(settings[:retry_jitter_ratio])
44
+ @respect_retry_after = normalize_respect_retry_after(settings)
45
+ end
46
+
47
+ def normalize_non_negative_integer(value)
48
+ return nil if value.nil?
49
+
50
+ [value.to_i, 0].max
51
+ end
52
+
53
+ def normalize_jitter_ratio(value)
54
+ return nil if value.nil?
55
+
56
+ [[value.to_f, 0.0].max, 1.0].min
57
+ end
58
+
59
+ def normalize_respect_retry_after(settings)
60
+ return nil unless settings.has_key?(:respect_retry_after)
61
+
62
+ settings[:respect_retry_after] ? true : false
63
+ end
32
64
  end
33
65
  end
34
66
  end
@@ -11,6 +11,7 @@ module Rudder
11
11
  'Content-Type' => 'application/json',
12
12
  'Content-Encoding' => 'gzip' }
13
13
  RETRIES = 10
14
+ MAX_RETRIES = RETRIES - 1
14
15
  end
15
16
 
16
17
  module Queue
@@ -28,9 +29,9 @@ module Rudder
28
29
 
29
30
  module BackoffPolicy
30
31
  MIN_TIMEOUT_MS = 100
31
- MAX_TIMEOUT_MS = 10000
32
- MULTIPLIER = 1.5
33
- RANDOMIZATION_FACTOR = 0.5
32
+ MAX_TIMEOUT_MS = 30000
33
+ MULTIPLIER = 2
34
+ RANDOMIZATION_FACTOR = 0.2
34
35
  end
35
36
  end
36
37
  end
@@ -74,14 +74,12 @@ module Rudder
74
74
  check_string(group_id, 'group_id')
75
75
 
76
76
  group_data = {
77
- type: 'group',
78
- groupId: group_id
77
+ :type => 'group',
78
+ :groupId => group_id
79
79
  }
80
80
 
81
81
  # Add traits if present
82
- if fields[:traits]
83
- group_data[:traits] = fields[:traits]
84
- end
82
+ group_data[:traits] = fields[:traits] if fields[:traits]
85
83
 
86
84
  common.merge(group_data)
87
85
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rudder/analytics/backoff_policy'
4
+ require 'rudder/analytics/defaults'
5
+ require 'net/http'
6
+ require 'net/https'
7
+ require 'time'
8
+
9
+ module Rudder
10
+ class Analytics
11
+ class RetryPolicy
12
+ include Rudder::Analytics::Defaults::Request
13
+
14
+ RETRYABLE_ERRORS = [
15
+ Timeout::Error,
16
+ EOFError,
17
+ IOError,
18
+ SocketError,
19
+ Errno::ECONNREFUSED,
20
+ Errno::ECONNRESET,
21
+ Errno::EHOSTUNREACH,
22
+ Errno::ENETUNREACH,
23
+ Errno::ETIMEDOUT,
24
+ Net::OpenTimeout,
25
+ Net::ReadTimeout,
26
+ Net::ProtocolError,
27
+ OpenSSL::SSL::SSLError
28
+ ].freeze
29
+
30
+ attr_reader :backoff_policy, :max_retries
31
+
32
+ def self.from_config(config)
33
+ backoff_policy = config.backoff_policy || Rudder::Analytics::BackoffPolicy.new(backoff_options(config))
34
+ new(
35
+ :retries => config.retries.nil? ? RETRIES : config.retries,
36
+ :respect_retry_after => config.respect_retry_after.nil? ? true : config.respect_retry_after,
37
+ :backoff_policy => backoff_policy
38
+ )
39
+ end
40
+
41
+ def self.backoff_options(config)
42
+ {
43
+ :min_timeout_ms => config.retry_base_delay || BackoffPolicy::MIN_TIMEOUT_MS,
44
+ :max_timeout_ms => config.max_retry_delay || BackoffPolicy::MAX_TIMEOUT_MS,
45
+ :multiplier => BackoffPolicy::MULTIPLIER,
46
+ :randomization_factor => config.retry_jitter_ratio || BackoffPolicy::RANDOMIZATION_FACTOR
47
+ }
48
+ end
49
+
50
+ def initialize(options = {})
51
+ @max_retries = normalize_max_retries(options)
52
+ @respect_retry_after = options.has_key?(:respect_retry_after) ? options[:respect_retry_after] : true
53
+ @backoff_policy = options[:backoff_policy] || Rudder::Analytics::BackoffPolicy.new
54
+ end
55
+
56
+ def max_attempts
57
+ @max_retries + 1
58
+ end
59
+
60
+ def retryable_status_code?(status_code)
61
+ status_code.zero? || status_code == 429 || (status_code >= 500 && status_code <= 599)
62
+ end
63
+
64
+ def retryable_exception?(exception)
65
+ RETRYABLE_ERRORS.any? { |error_class| exception.is_a?(error_class) }
66
+ end
67
+
68
+ def retry_delay_in_seconds(headers = {})
69
+ interval = next_backoff_interval(retry_after_delay_in_milliseconds(headers))
70
+ interval.to_f / 1000
71
+ end
72
+
73
+ private
74
+
75
+ def normalize_max_retries(options)
76
+ if options.has_key?(:max_retries)
77
+ [options[:max_retries].to_i, 0].max
78
+ elsif options.has_key?(:retries)
79
+ [options[:retries].to_i - 1, 0].max
80
+ else
81
+ MAX_RETRIES
82
+ end
83
+ end
84
+
85
+ def next_backoff_interval(retry_after_delay)
86
+ if @backoff_policy.method(:next_interval).arity.zero?
87
+ [@backoff_policy.next_interval, retry_after_delay].max
88
+ else
89
+ @backoff_policy.next_interval(retry_after_delay)
90
+ end
91
+ end
92
+
93
+ def retry_after_delay_in_milliseconds(headers)
94
+ return 0 unless @respect_retry_after
95
+
96
+ value = header_value(headers, 'Retry-After')
97
+ return 0 if value.nil?
98
+
99
+ parse_retry_after_delay(value)
100
+ end
101
+
102
+ def header_value(headers, name)
103
+ headers.find { |header_name, _| header_name.to_s.casecmp(name).zero? }&.last
104
+ end
105
+
106
+ def parse_retry_after_delay(value)
107
+ value = value.to_s.strip
108
+ return value.to_i * 1000 if value.match?(/\A\d+\z/)
109
+
110
+ retry_at = Time.httpdate(value)
111
+ [((retry_at - Time.now) * 1000).ceil, 0].max
112
+ rescue ArgumentError
113
+ 0
114
+ end
115
+ end
116
+ end
117
+ end
@@ -5,6 +5,7 @@ require 'rudder/analytics/utils'
5
5
  require 'rudder/analytics/response'
6
6
  require 'rudder/analytics/logging'
7
7
  require 'rudder/analytics/backoff_policy'
8
+ require 'rudder/analytics/retry_policy'
8
9
  require 'net/http'
9
10
  require 'net/https'
10
11
  require 'json'
@@ -23,8 +24,9 @@ module Rudder
23
24
  def initialize(config)
24
25
  @stub = config.stub || false
25
26
  @path = PATH
26
- @retries = config.retries || RETRIES
27
- @backoff_policy = config.backoff_policy || Rudder::Analytics::BackoffPolicy.new
27
+ @retry_policy = Rudder::Analytics::RetryPolicy.from_config(config)
28
+ @retries = @retry_policy.max_attempts
29
+ @backoff_policy = @retry_policy.backoff_policy
28
30
 
29
31
  uri = URI(config.data_plane_url)
30
32
 
@@ -37,88 +39,93 @@ module Rudder
37
39
  @gzip = config.gzip.nil? ? true : config.gzip
38
40
  end
39
41
 
40
- # Sends a batch of messages to the API
41
- #
42
- # @return [Response] API response
43
42
  def send(write_key, batch)
44
43
  logger.debug("Sending request for #{batch.length} items")
45
44
 
46
- last_response, exception = retry_with_backoff(@retries) do
47
- status_code, body = send_request(write_key, batch)
48
- error = body
49
- # rudder server now return 'OK'
50
- # begin
51
- # error = JSON.parse(body)['error']
52
- # rescue StandardError
53
- # error = JSON.parse(body.to_json)
54
- # end
55
-
56
- # puts error
57
- should_retry = should_retry_request?(status_code, body)
58
- logger.debug("Response status code: #{status_code}")
59
- logger.debug("Response error: #{error}") if error
60
-
61
- [Response.new(status_code, error), should_retry]
62
- end
45
+ retries = 0
63
46
 
64
- if exception
65
- logger.error(exception.message)
66
- exception.backtrace.each { |line| logger.error(line) }
67
- Response.new(-1, exception.to_s)
68
- else
69
- last_response
47
+ loop do
48
+ begin
49
+ response, headers = build_response(write_key, batch)
50
+ return response unless should_retry_request?(response.status, response.error)
51
+ return response unless retries_remaining?(retries)
52
+
53
+ retries = retry_request(retries, headers, "status #{response.status}")
54
+ rescue StandardError => e
55
+ return error_response(e) unless retryable_exception?(e)
56
+ return error_response(e) unless retries_remaining?(retries)
57
+
58
+ retries = retry_exception(retries, e)
59
+ end
70
60
  end
71
61
  end
72
62
 
73
- # Closes a persistent connection if it exists
74
63
  def shutdown
75
64
  @http.finish if @http.started?
76
65
  end
77
66
 
78
67
  private
79
68
 
69
+ def build_response(write_key, batch)
70
+ status_code, body, headers = send_request(write_key, batch)
71
+ error = body
72
+ logger.debug("Response status code: #{status_code}")
73
+ logger.debug("Response error: #{error}") if error
74
+
75
+ [Response.new(status_code, error), headers]
76
+ end
77
+
78
+ def retries_remaining?(retries)
79
+ retries < @retry_policy.max_retries
80
+ end
81
+
82
+ def retry_request(retries, headers, reason)
83
+ retries += 1
84
+ sleep_before_retry(retries, headers, reason)
85
+ retries
86
+ end
87
+
88
+ def retry_exception(retries, exception)
89
+ retries += 1
90
+ reset_connection
91
+ sleep_before_retry(retries, {}, "transport error #{exception.class.name}")
92
+ retries
93
+ end
94
+
80
95
  def should_retry_request?(status_code, body)
81
- if status_code >= 500
82
- true # Server error
83
- elsif status_code == 429
84
- true # Rate limited
85
- elsif status_code >= 400
86
- logger.error(body)
87
- false # Client error. Do not retry, but log
88
- else
89
- false
90
- end
96
+ logger.error(body) if status_code >= 400 && !retryable_status_code?(status_code)
97
+
98
+ retryable_status_code?(status_code)
91
99
  end
92
100
 
93
- # Takes a block that returns [result, should_retry].
94
- #
95
- # Retries upto `retries_remaining` times, if `should_retry` is false or
96
- # an exception is raised. `@backoff_policy` is used to determine the
97
- # duration to sleep between attempts
98
- #
99
- # Returns [last_result, raised_exception]
100
- def retry_with_backoff(retries_remaining, &block)
101
- result, caught_exception = nil
102
- should_retry = false
103
-
104
- begin
105
- result, should_retry = yield
106
- return [result, nil] unless should_retry
107
- rescue StandardError => e
108
- should_retry = true
109
- caught_exception = e
110
- end
101
+ def retryable_status_code?(status_code)
102
+ @retry_policy.retryable_status_code?(status_code)
103
+ end
111
104
 
112
- if should_retry && (retries_remaining > 1)
113
- logger.debug("Retrying request, #{retries_remaining} retries left")
114
- sleep(@backoff_policy.next_interval.to_f / 1000)
115
- retry_with_backoff(retries_remaining - 1, &block)
116
- else
117
- [result, caught_exception]
118
- end
105
+ def retryable_exception?(exception)
106
+ @retry_policy.retryable_exception?(exception)
107
+ end
108
+
109
+ def sleep_before_retry(retry_number, headers, reason)
110
+ delay = @retry_policy.retry_delay_in_seconds(headers)
111
+ remaining = @retry_policy.max_retries - retry_number
112
+ logger.debug("Retrying request after #{reason} in #{delay}s " \
113
+ "(attempt #{retry_number} of #{@retry_policy.max_attempts}, #{remaining} retries left)")
114
+ sleep(delay) if delay.positive?
115
+ end
116
+
117
+ def error_response(exception)
118
+ logger.error(exception.message)
119
+ exception.backtrace&.each { |line| logger.error(line) }
120
+ Response.new(-1, exception.to_s)
121
+ end
122
+
123
+ def reset_connection
124
+ @http.finish if @http.started?
125
+ rescue StandardError
126
+ nil
119
127
  end
120
128
 
121
- # Sends a request for the batch, returns [status_code, body]
122
129
  def send_request(write_key, batch)
123
130
  payload = {
124
131
  :batch => batch.messages
@@ -127,27 +134,39 @@ module Rudder
127
134
  logger.debug "stubbed request to #{@path}: " \
128
135
  "write key = #{write_key}, batch = #{JSON.generate(payload)}"
129
136
 
130
- [200, '{}']
137
+ [200, '{}', {}]
131
138
  else
132
139
 
133
- headers = HEADERS
134
-
135
- if @gzip
136
- gzip = Zlib::GzipWriter.new(StringIO.new)
137
- gzip << payload.to_json
138
- payload = gzip.close.string
139
- else
140
- headers.delete('Content-Encoding')
141
- payload = JSON.generate(payload)
142
- end
140
+ payload, headers = encoded_payload(payload)
143
141
 
144
142
  request = Net::HTTP::Post.new(@path, headers)
145
143
  request.basic_auth(write_key, nil)
146
144
  @http.start unless @http.started? # Maintain a persistent connection
147
145
  response = @http.request(request, payload)
148
- [response.code.to_i, response.body]
146
+ [response.code.to_i, response.body, response_headers(response)]
149
147
  end
150
148
  end
149
+
150
+ def encoded_payload(payload)
151
+ headers = HEADERS.dup
152
+
153
+ if @gzip
154
+ gzip = Zlib::GzipWriter.new(StringIO.new)
155
+ gzip << payload.to_json
156
+ payload = gzip.close.string
157
+ else
158
+ headers.delete('Content-Encoding')
159
+ payload = JSON.generate(payload)
160
+ end
161
+
162
+ [payload, headers]
163
+ end
164
+
165
+ def response_headers(response)
166
+ headers = {}
167
+ response.each_header { |name, value| headers[name] = value }
168
+ headers
169
+ end
151
170
  end
152
171
  end
153
172
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rudder
4
4
  class Analytics
5
- VERSION = '3.1.1'
5
+ VERSION = '3.2.0'
6
6
  end
7
7
  end
@@ -47,8 +47,8 @@ module Rudder
47
47
 
48
48
  # res = Request.new(:data_plane_url => @data_plane_url, :ssl => @ssl).post @write_key, @batch
49
49
  res = @transport.send @write_key, @batch
50
- unless res.status == 200
51
- @on_error.call(res.status, res.error)
50
+ unless success_status?(res.status)
51
+ @on_error.call(res.status, res.error)
52
52
  @on_error_with_messages.call(res.status, res.error, @batch.messages)
53
53
  end
54
54
 
@@ -66,6 +66,10 @@ module Rudder
66
66
 
67
67
  private
68
68
 
69
+ def success_status?(status)
70
+ status >= 200 && status < 300
71
+ end
72
+
69
73
  def consume_message_from_queue!
70
74
  @batch << @queue.pop
71
75
  rescue MessageBatch::JSONGenerationError => e
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rudder-sdk-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rudder
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-02-26 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: commander
@@ -141,6 +141,7 @@ files:
141
141
  - lib/rudder/analytics/logging.rb
142
142
  - lib/rudder/analytics/message_batch.rb
143
143
  - lib/rudder/analytics/response.rb
144
+ - lib/rudder/analytics/retry_policy.rb
144
145
  - lib/rudder/analytics/test_queue.rb
145
146
  - lib/rudder/analytics/transport.rb
146
147
  - lib/rudder/analytics/utils.rb