abmeter 0.4.0 → 0.5.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: f33d4944ddd5acac49f58a672004aa3460a7d8435a651ad6172d88960f02ebdb
4
- data.tar.gz: b800e19818cc9d408380c14ae7e7fc2bad8d421839f970f14e259b327acb314c
3
+ metadata.gz: 127c157ec200964f199fb170d036d9b9898f717a1634002d02dcd211c3296387
4
+ data.tar.gz: 9774017d52cf7b9925eddd837fd4972a693cfd5b76f11c4fe20bd8692d85ddec
5
5
  SHA512:
6
- metadata.gz: 83e08361797d68f6367b1651358557b25635e8a8554a9fb3e7dd79169f08b0c7443a52da385535cda59a25b4276cf9424c696dc4130c2a42f75e114412555f8b
7
- data.tar.gz: e60a8479335b24cc3f57b24a5af553ad77b5a8451c3b8801650ca13721e3281cda29d9c54717f064393f1b6d816157bc6b84a83290e56bb4abcef4edb8c2bbf0
6
+ metadata.gz: 6174b2a6d2308658310f5a2f35df3d3a986f0979b2a87cd318b3f051469074fa1cf2ead76a53b4a18569dd50d28181b6bdde256c9086be27612e3e12525efa32
7
+ data.tar.gz: eee251586f7279d59edbb6049e5265156afd1858383752dfb4c280d97dc32dd04543c2e44cf8e8f9348046194ec6bcf5b91c651299a0b53e17716e0fdd069ca6
data/README.md CHANGED
@@ -49,7 +49,7 @@ text = ABMeter.resolve_parameter(user: user, parameter_slug: 'welcome_text')
49
49
  ABMeter.track_event('user_purchases_plan', current_user.id, { plan: purchased_plan.name, price: purchased_plan.price })
50
50
  ```
51
51
 
52
- The two calls are one loop: the experiment varies `welcome_text`, and the event feeds a metric whose effect the experiment measures across variants. Note that `resolve_parameter` is **not** a pure read — it resolves the value locally and records an exposure in the background; use `get_exposure` for the same resolution without submitting. Full guides live at [abmeter.ai](https://abmeter.ai).
52
+ The two calls are one loop: the experiment varies `welcome_text`, and the event feeds a metric whose effect the experiment measures across variants. Note that `resolve_parameter` is **not** a pure read — it resolves the value locally and records an exposure in the background. Full guides live at [abmeter.ai](https://abmeter.ai).
53
53
 
54
54
  ## Shutdown
55
55
 
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Regenerates the cross-language num-utils parity fixture consumed by the
5
- # Python SDK (sdk-python/tests/test_num_utils_parity.py).
4
+ # Regenerates the cross-language num-utils parity fixture consumed by every
5
+ # ported SDK (sdk-python/tests/test_num_utils_parity.py,
6
+ # sdk-node/tests/num-utils-parity.test.ts).
6
7
  #
7
8
  # Emits 1000 [salt, user_id, expected] triples where `expected` is the integer
8
- # returned by ABMeter::Core::Utils::NumUtils.to_percentage. The Python
9
+ # returned by ABMeter::Core::Utils::NumUtils.to_percentage. Each ported
9
10
  # implementation must reproduce every row. Run from the abmeter-sdk directory:
10
11
  #
11
12
  # bundle exec ruby bin/generate_num_utils_parity_fixture.rb
@@ -15,9 +16,10 @@
15
16
  require 'json'
16
17
  require_relative '../lib/abmeter/core/utils/num_utils'
17
18
 
18
- FIXTURE_PATH = File.expand_path(
19
- '../../sdk-python/tests/fixtures/num_utils_parity.json', __dir__
20
- )
19
+ FIXTURE_PATHS = [
20
+ '../../sdk-python/tests/fixtures/num_utils_parity.json',
21
+ '../../sdk-node/tests/fixtures/num_utils_parity.json'
22
+ ].map { |path| File.expand_path(path, __dir__) }
21
23
  ROW_COUNT = 1000
22
24
 
23
25
  rows = Array.new(ROW_COUNT) do |i|
@@ -28,5 +30,7 @@ rows = Array.new(ROW_COUNT) do |i|
28
30
  [salt, user_id, ABMeter::Core::Utils::NumUtils.to_percentage(salt, user_id)]
29
31
  end
30
32
 
31
- File.write(FIXTURE_PATH, "#{JSON.pretty_generate(rows)}\n")
32
- puts "Wrote #{rows.size} rows to #{FIXTURE_PATH}"
33
+ FIXTURE_PATHS.each do |path|
34
+ File.write(path, "#{JSON.pretty_generate(rows)}\n")
35
+ puts "Wrote #{rows.size} rows to #{path}"
36
+ end
@@ -2,10 +2,16 @@ require 'active_support/core_ext/hash/indifferent_access'
2
2
 
3
3
  module ABMeter
4
4
  class APIError < StandardError
5
- attr_reader :code, :details, :status
5
+ RATE_LIMITED_STATUS = 429
6
+ DEFAULT_RETRY_AFTER = 30 # seconds, when a 429 has no usable Retry-After
7
+ MIN_RETRY_AFTER = 1 # seconds, so a zero cannot spin a drain loop
8
+ MAX_RETRY_AFTER = 600 # seconds, the API's rate-limit window
9
+
10
+ attr_reader :code, :details, :status, :retry_after
6
11
 
7
12
  def initialize(response)
8
13
  @status = response.status
14
+ @retry_after = parse_retry_after(response.headers['Retry-After']) if rate_limited?
9
15
  error_body = response.body
10
16
 
11
17
  if error_body.is_a?(Hash)
@@ -39,6 +45,10 @@ module ABMeter
39
45
  @status >= 500 || @status == 408 || @status == 429
40
46
  end
41
47
 
48
+ def rate_limited?
49
+ @status == RATE_LIMITED_STATUS
50
+ end
51
+
42
52
  def partial_failure?
43
53
  @status == 400 && @details.is_a?(Hash) && !@details[:failures].nil? && !@details[:failures].empty?
44
54
  end
@@ -46,5 +56,15 @@ module ABMeter
46
56
  def failure_count
47
57
  @details&.dig(:invalid_count) || 0
48
58
  end
59
+
60
+ private
61
+
62
+ # Retry-After as delta-seconds only; the API never sends an HTTP date.
63
+ def parse_retry_after(value)
64
+ seconds = Integer(value.to_s.strip, 10, exception: false)
65
+ return DEFAULT_RETRY_AFTER if seconds.nil? || seconds.negative?
66
+
67
+ seconds.clamp(MIN_RETRY_AFTER, MAX_RETRY_AFTER)
68
+ end
49
69
  end
50
70
  end
@@ -18,6 +18,7 @@ module ABMeter
18
18
  @stopping = false
19
19
  @stop_mutex = Mutex.new
20
20
  @stop_signal = ConditionVariable.new
21
+ @paused_until = nil
21
22
 
22
23
  class << self
23
24
  attr_reader :api_client, :flush_interval, :logger, :retry_queue
@@ -48,7 +49,10 @@ module ABMeter
48
49
  })
49
50
  end
50
51
 
52
+ # A no-op while a 429 pause lasts: new items wait in the main queue.
51
53
  def flush
54
+ return if paused?
55
+
52
56
  items = []
53
57
 
54
58
  @mutex.synchronize do
@@ -56,7 +60,7 @@ module ABMeter
56
60
  process_retry_queue
57
61
 
58
62
  # Then process new items
59
- items << @queue.pop while !@queue.empty? && items.size < BATCH_SIZE
63
+ items << @queue.pop while !paused? && !@queue.empty? && items.size < BATCH_SIZE
60
64
  end
61
65
 
62
66
  # Group by type and submit
@@ -120,6 +124,10 @@ module ABMeter
120
124
  @queue.size
121
125
  end
122
126
 
127
+ def paused?
128
+ !@paused_until.nil? && monotonic_now < @paused_until
129
+ end
130
+
123
131
  private
124
132
 
125
133
  def start_worker
@@ -138,7 +146,10 @@ module ABMeter
138
146
  end
139
147
  # Final drain on graceful stop — bounded by the caller's join timeout.
140
148
  begin
141
- flush until @queue.empty? && @retry_queue.empty?
149
+ until @queue.empty? && @retry_queue.empty?
150
+ wait_out_pause
151
+ flush
152
+ end
142
153
  rescue StandardError => e
143
154
  log_error("Final flush error: #{e.message}")
144
155
  end
@@ -155,6 +166,11 @@ module ABMeter
155
166
  end
156
167
  end
157
168
 
169
+ def wait_out_pause
170
+ remaining = @paused_until.to_f - monotonic_now
171
+ sleep(remaining) if remaining.positive?
172
+ end
173
+
158
174
  def request_stop
159
175
  @stop_mutex.synchronize do
160
176
  @stopping = true
@@ -170,6 +186,7 @@ module ABMeter
170
186
  @flush_interval = DEFAULT_FLUSH_INTERVAL
171
187
  @logger = nil
172
188
  @stopping = false
189
+ @paused_until = nil
173
190
  end
174
191
 
175
192
  def submit_exposures(exposures)
@@ -188,10 +205,18 @@ module ABMeter
188
205
  return
189
206
  end
190
207
 
208
+ if paused?
209
+ add_to_retry_queue(type, items)
210
+ return
211
+ end
212
+
191
213
  yield items
192
214
  # Success - nothing to do
193
215
  rescue ABMeter::APIError => e
194
- if e.retryable?
216
+ if e.rate_limited?
217
+ pause_submissions(e.retry_after, type, items.size)
218
+ add_to_retry_queue(type, items)
219
+ elsif e.retryable?
195
220
  log_error("Retryable error submitting #{items.size} #{type}s: #{e.message}")
196
221
  add_to_retry_queue(type, items)
197
222
  elsif e.partial_failure?
@@ -209,21 +234,23 @@ module ABMeter
209
234
 
210
235
  def add_to_retry_queue(type, items)
211
236
  @mutex.synchronize do
212
- items.each do |item|
213
- # Only add if we haven't exceeded max queue size
214
- if @retry_queue.size < MAX_RETRY_QUEUE_SIZE
215
- @retry_queue << {
216
- type: type,
217
- data: item,
218
- attempts: 0
219
- }
220
- else
221
- log_error("Retry queue full, dropping #{type}")
222
- end
223
- end
237
+ room = MAX_RETRY_QUEUE_SIZE - @retry_queue.size
238
+ kept = items.first([room, 0].max)
239
+ @retry_queue.concat(kept.map { |item| { type: type, data: item, attempts: 0 } })
240
+ dropped = items.size - kept.size
241
+ log_error("Retry queue full, dropping #{dropped} #{type}s") if dropped.positive?
224
242
  end
225
243
  end
226
244
 
245
+ def pause_submissions(seconds, type, count)
246
+ @paused_until = monotonic_now + seconds
247
+ log_warn("Rate limited, pausing submissions for #{seconds}s (#{count} #{type}s held for retry)")
248
+ end
249
+
250
+ def monotonic_now
251
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
+ end
253
+
227
254
  def process_retry_queue
228
255
  return if @retry_queue.empty?
229
256
 
@@ -249,6 +276,8 @@ module ABMeter
249
276
 
250
277
  def process_retry_batch(type, items)
251
278
  return [] if items.empty?
279
+ # Held, not attempted: a pause set earlier in this flush.
280
+ return items if paused?
252
281
 
253
282
  # Increment attempts and filter out max retries
254
283
  active_items = items.filter_map do |item|
@@ -269,7 +298,12 @@ module ABMeter
269
298
  yield data
270
299
  [] # Success - return empty array
271
300
  rescue ABMeter::APIError => e
272
- if e.retryable?
301
+ if e.rate_limited?
302
+ pause_submissions(e.retry_after, type, data.size)
303
+ # The wait is not an attempt.
304
+ active_items.each { |item| item[:attempts] -= 1 }
305
+ active_items
306
+ elsif e.retryable?
273
307
  log_error("Retry failed with retryable error for #{data.size} #{type}s: #{e.message}")
274
308
  active_items # Return all items for retry
275
309
  elsif e.partial_failure?
@@ -291,9 +325,11 @@ module ABMeter
291
325
  end
292
326
 
293
327
  def log_error(message)
294
- return unless @logger
328
+ @logger&.error("AsyncSubmitter: #{message}")
329
+ end
295
330
 
296
- @logger.error("AsyncSubmitter: #{message}")
331
+ def log_warn(message)
332
+ @logger&.warn("AsyncSubmitter: #{message}")
297
333
  end
298
334
  end
299
335
 
@@ -44,6 +44,8 @@ module ABMeter
44
44
  f.request :json
45
45
  f.response :json
46
46
  f.headers['Authorization'] = "Bearer #{@api_key}"
47
+ f.options.timeout = DEFAULT_REQUEST_TIMEOUT
48
+ f.options.open_timeout = DEFAULT_REQUEST_TIMEOUT
47
49
  f.adapter Faraday.default_adapter
48
50
  end
49
51
  end
@@ -8,4 +8,5 @@ module ABMeter
8
8
  DEFAULT_MAX_SUBMIT_ATTEMPTS = 3
9
9
  DEFAULT_MAX_RETRY_QUEUE_SIZE = 1000
10
10
  DEFAULT_LOG_LEVEL = :error
11
+ DEFAULT_REQUEST_TIMEOUT = 30 # seconds
11
12
  end
@@ -1,16 +1,22 @@
1
+ require_relative 'error_safety'
2
+
1
3
  module ABMeter
2
4
  class ResolverProvider
5
+ include ErrorSafety
6
+
3
7
  attr_reader :last_fetched_at
4
8
 
5
9
  def initialize(config:, api_client: nil)
6
10
  raise 'Either api_key or static_config must be provided' unless config.api_key || config.static_config
7
11
 
12
+ @config = config
8
13
  @api_client = api_client
9
14
  @json_config = config.static_config
10
15
  @fetch_interval = config.fetch_interval
11
16
 
12
17
  @resolver = nil
13
18
  @last_fetched_at = nil
19
+ @last_attempted_at = nil
14
20
 
15
21
  # Build resolver immediately if using JSON config
16
22
  @resolver = ABMeter::Core.build_resolver_from_json(@json_config) if @json_config
@@ -41,14 +47,23 @@ module ABMeter
41
47
  def refresh_config_if_needed
42
48
  return if @json_config # No refresh needed for JSON config
43
49
 
44
- update_resolver if !@resolver || time_to_refresh_config?
50
+ return unless !@resolver || time_to_refresh_config?
51
+
52
+ begin
53
+ update_resolver
54
+ rescue StandardError => e
55
+ raise unless @resolver
56
+
57
+ serve_last_known_config(e)
58
+ end
45
59
  end
46
60
 
47
61
  def time_to_refresh_config?
48
- @last_fetched_at.nil? || (Time.now - @last_fetched_at > @fetch_interval)
62
+ @last_attempted_at.nil? || (Time.now - @last_attempted_at > @fetch_interval)
49
63
  end
50
64
 
51
65
  def update_resolver
66
+ @last_attempted_at = Time.now
52
67
  response = @api_client.get_assignment_config
53
68
  config_data = response[:config]
54
69
 
@@ -56,6 +71,11 @@ module ABMeter
56
71
  @last_fetched_at = Time.now
57
72
  end
58
73
 
74
+ def serve_last_known_config(error)
75
+ log_error('Failed to refresh assignment config, serving last known config', error)
76
+ call_error_callback(error)
77
+ end
78
+
59
79
  def queue_exposure(exposure)
60
80
  AsyncSubmitter.queue_exposure(exposure)
61
81
  end
@@ -1,3 +1,3 @@
1
1
  module ABMeter
2
- VERSION = '0.4.0'.freeze
2
+ VERSION = '0.5.0'.freeze
3
3
  end
data/lib/abmeter.rb CHANGED
@@ -48,7 +48,6 @@ module ABMeter
48
48
  config.base_url = DEFAULT_BASE_URL
49
49
  yield(config)
50
50
  config.validate!
51
- # Set logger level if both logger and log_level are configured
52
51
  config.send(:set_logger_level, config.log_level)
53
52
  @config = config
54
53
 
@@ -126,13 +125,13 @@ module ABMeter
126
125
 
127
126
  # Logging and error handling
128
127
  attr_accessor :logger # Logger instance (auto-detected by default)
129
- attr_accessor :log_level # Log level (:debug, :info, :warn, :error)
128
+ attr_accessor :log_level # Level for the SDK's own logger; a logger you pass in keeps its level
130
129
  attr_accessor :error_callback # Proc called on errors
131
130
 
132
131
  def initialize
133
132
  @flush_interval = DEFAULT_FLUSH_INTERVAL
134
133
  @fetch_interval = DEFAULT_FETCH_INTERVAL
135
- @logger = detect_logger
134
+ @logger = host_logger || (@own_logger = build_own_logger)
136
135
  end
137
136
 
138
137
  def validate!
@@ -161,13 +160,13 @@ module ABMeter
161
160
  private
162
161
 
163
162
  def set_logger_level(log_level)
164
- return unless @logger
163
+ return unless @own_logger && @logger.equal?(@own_logger)
165
164
 
166
- @logger.level = logger_level_for(@logger, log_level || DEFAULT_LOG_LEVEL) if @logger.respond_to?(:level=)
165
+ @own_logger.level = logger_level_for(@own_logger, log_level || DEFAULT_LOG_LEVEL)
167
166
  end
168
167
 
169
168
  def logger_level_for(logger, log_level)
170
- if defined?(Logger) && (logger.is_a?(Logger) || (defined?(Rails) && logger == Rails.logger))
169
+ if defined?(Logger) && logger.is_a?(Logger)
171
170
  return case log_level
172
171
  when :debug then Logger::DEBUG
173
172
  when :info then Logger::INFO
@@ -181,18 +180,15 @@ module ABMeter
181
180
  @log_level
182
181
  end
183
182
 
184
- def detect_logger
185
- # Try Rails logger first
186
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
187
- Rails.logger
188
- # Try Semantic Logger
189
- elsif defined?(SemanticLogger)
190
- SemanticLogger['ABMeter']
191
- # Default to Ruby's Logger
192
- else
193
- require 'logger'
194
- Logger.new($stdout, progname: 'ABMeter')
195
- end
183
+ def host_logger
184
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
185
+ end
186
+
187
+ def build_own_logger
188
+ return SemanticLogger['ABMeter'] if defined?(SemanticLogger)
189
+
190
+ require 'logger'
191
+ Logger.new($stdout, progname: 'ABMeter')
196
192
  end
197
193
  end
198
194
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abmeter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ABMeter
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-25 00:00:00.000000000 Z
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -41,7 +41,6 @@ dependencies:
41
41
  description: ABMeter SDK is a client library for interacting with the ABMeter testing
42
42
  service
43
43
  email:
44
- - info@abmeter.com
45
44
  executables: []
46
45
  extensions: []
47
46
  extra_rdoc_files: []