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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +6 -8
- data/lib/nb_api_client/configuration.rb +2 -5
- data/lib/nb_api_client/rate_limiter.rb +39 -48
- data/lib/nb_api_client/version.rb +1 -1
- data/lib/nb_api_client.rb +0 -11
- metadata +2 -30
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef855607df4fa9e26f513dd0ead54a8110b30baf30af94d21f10a8b11de06b45
|
|
4
|
+
data.tar.gz: 9ddcd3bc22a1b7947fc74e0d789a498a5b252b980e0cefb92cf916afcdedb8af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
61
|
-
#
|
|
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
|
|
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
|
|
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
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
70
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
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.
|
|
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
|
|
98
|
+
handling, and a cache-store-backed sliding-window rate limiter shared across processes.
|
|
127
99
|
email:
|
|
128
100
|
executables: []
|
|
129
101
|
extensions: []
|