nb_api_client 0.1.0 → 0.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: 2082f32bdb205caf63485893abe3199b480329fde2079592e38ba4b53b37660d
4
- data.tar.gz: 793459a2c5f1271be72812d91cbf8d251795c8dffa5d73edb4d0d68047ca62e8
3
+ metadata.gz: ef855607df4fa9e26f513dd0ead54a8110b30baf30af94d21f10a8b11de06b45
4
+ data.tar.gz: 9ddcd3bc22a1b7947fc74e0d789a498a5b252b980e0cefb92cf916afcdedb8af
5
5
  SHA512:
6
- metadata.gz: efd0d26a0b2ac02fa0cd418b0ff27281fea5afa8122790d0ef36b83dc81216604d0395576b01207d4321de2cc39a446d03ee1b988ed1f5d18b85f79bbc5b26c2
7
- data.tar.gz: d19ea9e67bb1689876e9d33d81c561ae452b59f41a5c736df151c4c7d90ef34a185a6b6c0602e1fcbfe496faf0fc90f15941fa99f4207c44112cf5c36c8cc3a8
6
+ metadata.gz: 027ae83f574dcfdccdfd1c0b138bc37f731ac24b5d7c0321874d7846115e99ff5d506ff896abb566770889024520a59825578b5933c5a6489994a5229f9f6735
7
+ data.tar.gz: afe137de2bc248504c9a83f6b4e84e266de8f077556db836f8a6c3601636aa2aed094a912994479ebcf08ada7e694165c107ed886559df6c0b7916b61653857a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.0] - 2026-07-16
6
+
7
+ - **Breaking:** Removed the `redis` and `connection_pool` gem dependencies, along with
8
+ `config.redis_url` and `config.rate_limit_pool_size`. `NbApiClient::RateLimiter` now
9
+ implements its sliding window (a bucketed sliding-window-counter algorithm) on top of
10
+ the existing `configuration.cache_store` (defaults to `Rails.cache`) instead of a
11
+ dedicated Redis connection pool and Lua script. Rate limiting is disabled entirely if
12
+ no `cache_store` is configured.
13
+
5
14
  ## [0.1.1] - 2026-07-16
6
15
 
7
16
  - Publish to rubygems.org instead of GitLab's RubyGems registry (which is
data/README.md CHANGED
@@ -5,7 +5,7 @@ A rate-limited, token-refreshing HTTP client for the NationBuilder API, for use
5
5
  It provides two pieces:
6
6
 
7
7
  - `NbApiClient::Request` — makes a single authenticated API call, transparently retrying on rate limits and refreshing expired OAuth tokens.
8
- - `NbApiClient::RateLimiter` — a Redis-backed sliding-window rate limiter shared across every process/thread in your fleet, so you don't blow through NationBuilder's server-side rate limit when running many workers.
8
+ - `NbApiClient::RateLimiter` — a sliding-window rate limiter, backed by your configured `cache_store` (defaults to `Rails.cache`), shared across every process/thread in your fleet, so you don't blow through NationBuilder's server-side rate limit when running many workers. If no `cache_store` is configured, rate limiting is disabled entirely.
9
9
 
10
10
  ## Installation
11
11
 
@@ -57,8 +57,9 @@ NbApiClient.configure do |config|
57
57
  config.unauthorized_error_threshold = 25
58
58
  config.unauthorized_error_window = 5 * 60
59
59
 
60
- # Cache store used to count those unauthorized responses. Defaults to
61
- # Rails.cache; must respond to #increment(key, amount, options).
60
+ # Cache store used to count those unauthorized responses, and (below) to
61
+ # back the sliding-window rate limiter. Defaults to Rails.cache; must
62
+ # respond to #increment(key, amount, options), #decrement, and #read.
62
63
  config.cache_store = Rails.cache
63
64
 
64
65
  # Logger for rate-limit/retry warnings. Defaults to Rails.logger.
@@ -69,12 +70,11 @@ NbApiClient.configure do |config|
69
70
  # rather stub HTTP at a lower level (e.g. WebMock/VCR) in your test suite.
70
71
  config.short_circuit_in_test = true
71
72
 
72
- # Sliding-window rate limit enforced via Redis across every process.
73
+ # Sliding-window rate limit enforced via `cache_store` across every process.
74
+ # Disabled entirely if `cache_store` is nil.
73
75
  config.rate_limit = 200 # requests
74
76
  config.rate_limit_window_seconds = 10 # per this many seconds
75
77
  config.rate_limit_max_wait_seconds = 120
76
- config.rate_limit_pool_size = 6 # Redis connection pool size
77
- config.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
78
78
  end
79
79
  ```
80
80
 
@@ -99,8 +99,6 @@ bundle install
99
99
  bundle exec rake test
100
100
  ```
101
101
 
102
- The two Lua-script integration tests in `rate_limiter_test.rb` are skipped automatically if no Redis is reachable at `REDIS_URL`.
103
-
104
102
  ## Releasing a new version
105
103
 
106
104
  1. Bump `NbApiClient::VERSION` in `lib/nb_api_client/version.rb` and add an entry to `CHANGELOG.md`.
@@ -31,14 +31,13 @@ module NbApiClient
31
31
  attr_accessor :max_token_retries
32
32
 
33
33
  # Sliding-window rate limit: at most `rate_limit` requests per `rate_limit_window_seconds`,
34
- # enforced across all processes via Redis.
34
+ # enforced across all processes via `cache_store` (above). If `cache_store` is nil,
35
+ # rate limiting is disabled entirely.
35
36
  attr_accessor :rate_limit
36
37
  attr_accessor :rate_limit_window_seconds
37
38
  attr_accessor :rate_limit_max_wait_seconds
38
39
  attr_accessor :rate_limit_poll_interval
39
40
  attr_accessor :rate_limit_max_jitter
40
- attr_accessor :rate_limit_pool_size
41
- attr_accessor :redis_url
42
41
 
43
42
  def initialize
44
43
  @cache_store = (defined?(Rails) && Rails.respond_to?(:cache)) ? Rails.cache : nil
@@ -55,8 +54,6 @@ module NbApiClient
55
54
  @rate_limit_max_wait_seconds = ENV.fetch("API_RATE_MAX_WAIT_SECONDS", 120).to_i
56
55
  @rate_limit_poll_interval = 0.25
57
56
  @rate_limit_max_jitter = 0.25
58
- @rate_limit_pool_size = ENV.fetch("RATE_LIMITER_POOL_SIZE", 6).to_i
59
- @redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
60
57
  end
61
58
  end
62
59
  end
@@ -1,42 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NbApiClient
4
- # A global (cross-process, cross-thread) distributed rate limiter backed by Redis.
5
- # Implements a sliding-window algorithm in a Lua script (atomic ZADD/ZREMRANGEBYSCORE/ZCARD
6
- # on a sorted set) to enforce "N requests per window" across every process sharing the
7
- # same Redis. All callers share one window (configure via NbApiClient.configure).
4
+ # A global (cross-process, cross-thread) distributed rate limiter backed by
5
+ # configuration.cache_store (the same store used for unauthorized-response
6
+ # tracking; defaults to Rails.cache). Implements a "sliding window counter"
7
+ # algorithm: each window is divided into fixed buckets, and the previous
8
+ # bucket's count is weighted by how much of it still overlaps the sliding
9
+ # window. This approximates a true sliding window without requiring
10
+ # sorted-set support, so it works with any ActiveSupport::Cache::Store
11
+ # (memory, Redis, Memcached, ...). All callers share one window (configure
12
+ # via NbApiClient.configure).
13
+ #
14
+ # If no cache_store is configured, rate limiting is disabled entirely (every
15
+ # request is allowed through immediately).
8
16
  class RateLimiter
9
17
  class RateLimitExhausted < StandardError; end
10
18
 
11
- # 1. Remove all entries older than (now - window) to slide the window
12
- # 2. Count remaining entries in the set
13
- # 3. If under the limit, add a new entry (scored by timestamp) and return the new count
14
- # 4. If at/over the limit, return -1 (rejected)
15
- #
16
- # The sorted set key is given a TTL of 2*window as a safety net to avoid
17
- # unbounded memory growth if traffic stops entirely.
18
- ACQUIRE_SLOT_SCRIPT = <<~LUA
19
- local key = KEYS[1]
20
- local limit = tonumber(ARGV[1])
21
- local window = tonumber(ARGV[2])
22
- local now = tonumber(ARGV[3])
23
- local member = ARGV[4]
24
-
25
- -- Prune entries outside the sliding window
26
- redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
27
-
28
- local count = redis.call('ZCARD', key)
29
-
30
- if count < limit then
31
- redis.call('ZADD', key, now, member)
32
- -- Safety-net expiry so the key doesn't live forever if traffic stops
33
- redis.call('EXPIRE', key, window * 2)
34
- return count + 1
35
- else
36
- return -1
37
- end
38
- LUA
39
-
40
19
  # Blocks until a request slot is available, then yields to the caller.
41
20
  # Raises RateLimitExhausted if the wait exceeds configuration.rate_limit_max_wait_seconds.
42
21
  def self.with_limit(&block)
@@ -66,33 +45,45 @@ module NbApiClient
66
45
  waited += sleep_time
67
46
  end
68
47
  end
69
- rescue ConnectionPool::TimeoutError, Redis::BaseError, RedisClient::Error => e
70
- # Fail open: if Redis is unavailable, allow the request through.
48
+ rescue RateLimitExhausted
49
+ raise
50
+ rescue StandardError => e
51
+ # Fail open: if the cache store is unavailable, allow the request through.
71
52
  # NbApiClient::Request's 429 handling acts as a safety net.
72
- config.logger&.warn("[NbApiClient::RateLimiter] Redis error, failing open: #{e.class} - #{e.message}")
53
+ config.logger&.warn("[NbApiClient::RateLimiter] cache store error, failing open: #{e.class} - #{e.message}")
73
54
  yield
74
55
  end
75
56
 
76
57
  private
77
58
 
78
59
  # Attempts to acquire a slot in the sliding window.
79
- # Returns the new count (positive) on success, or -1 if the window is full.
60
+ # Returns the new (weighted) count (positive) on success, or -1 if the window is full.
61
+ # Always succeeds (returns 1) if no cache_store is configured, i.e. rate limiting is disabled.
80
62
  def try_acquire_slot
81
63
  config = NbApiClient.configuration
64
+ store = config.cache_store
65
+ return 1 unless store
66
+
67
+ window = config.rate_limit_window_seconds
82
68
  now = Process.clock_gettime(Process::CLOCK_REALTIME)
83
- member = "#{now}:#{SecureRandom.hex(4)}"
69
+ current_bucket = (now / window).floor
70
+ previous_bucket = current_bucket - 1
71
+ elapsed_in_current = now - (current_bucket * window)
72
+ previous_weight = [(window - elapsed_in_current) / window, 0.0].max
84
73
 
85
- redis do |conn|
86
- conn.eval(
87
- ACQUIRE_SLOT_SCRIPT,
88
- keys: [@key],
89
- argv: [config.rate_limit.to_s, config.rate_limit_window_seconds.to_s, now.to_s, member]
90
- )
91
- end
92
- end
74
+ # Safety-net expiry so bucket keys don't live forever if traffic stops.
75
+ current_count = store.increment("#{@key}:#{current_bucket}", 1, expires_in: window * 2)
76
+ previous_count = store.read("#{@key}:#{previous_bucket}") || 0
93
77
 
94
- def redis(&block)
95
- NbApiClient.connection_pool.with(&block)
78
+ weighted_count = current_count + (previous_count * previous_weight)
79
+
80
+ if weighted_count <= config.rate_limit
81
+ weighted_count
82
+ else
83
+ # Undo the increment so this rejected attempt doesn't count against the window.
84
+ store.decrement("#{@key}:#{current_bucket}", 1)
85
+ -1
86
+ end
96
87
  end
97
88
  end
98
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NbApiClient
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/nb_api_client.rb CHANGED
@@ -10,8 +10,6 @@ require "active_support/core_ext/object/inclusion"
10
10
  require "httparty"
11
11
  require "addressable/uri"
12
12
  require "oauth2"
13
- require "redis"
14
- require "connection_pool"
15
13
 
16
14
  require_relative "nb_api_client/version"
17
15
  require_relative "nb_api_client/configuration"
@@ -28,14 +26,5 @@ module NbApiClient
28
26
  def configure
29
27
  yield(configuration)
30
28
  end
31
-
32
- # Lazily built so it always reflects the redis_url/pool_size in effect the
33
- # first time a request needs it (i.e. after any NbApiClient.configure block
34
- # has run).
35
- def connection_pool
36
- @connection_pool ||= ConnectionPool.new(size: configuration.rate_limit_pool_size, timeout: 3) {
37
- Redis.new(url: configuration.redis_url)
38
- }
39
- end
40
29
  end
41
30
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nb_api_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Flint
@@ -66,34 +66,6 @@ dependencies:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.0'
69
- - !ruby/object:Gem::Dependency
70
- name: redis
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '5.0'
76
- type: :runtime
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '5.0'
83
- - !ruby/object:Gem::Dependency
84
- name: connection_pool
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - ">="
88
- - !ruby/object:Gem::Version
89
- version: '2.4'
90
- type: :runtime
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '2.4'
97
69
  - !ruby/object:Gem::Dependency
98
70
  name: rake
99
71
  requirement: !ruby/object:Gem::Requirement
@@ -123,7 +95,7 @@ dependencies:
123
95
  - !ruby/object:Gem::Version
124
96
  version: '5.0'
125
97
  description: Wraps NationBuilder API requests with OAuth token refresh, deauthorization
126
- handling, and a Redis-backed sliding-window rate limiter shared across processes.
98
+ handling, and a cache-store-backed sliding-window rate limiter shared across processes.
127
99
  email:
128
100
  executables: []
129
101
  extensions: []