nb_api_client 0.1.0 → 0.3.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: 03ba7132dde127d46367c5a7e9731862657586a3a16da516a951a108ab1b89ed
4
+ data.tar.gz: 4d6e1c174a2ff5a1efe821ab92cbad01902271a18bfdc6c87724c83d1e79ef43
5
5
  SHA512:
6
- metadata.gz: efd0d26a0b2ac02fa0cd418b0ff27281fea5afa8122790d0ef36b83dc81216604d0395576b01207d4321de2cc39a446d03ee1b988ed1f5d18b85f79bbc5b26c2
7
- data.tar.gz: d19ea9e67bb1689876e9d33d81c561ae452b59f41a5c736df151c4c7d90ef34a185a6b6c0602e1fcbfe496faf0fc90f15941fa99f4207c44112cf5c36c8cc3a8
6
+ metadata.gz: 03efb68afa45a268187f5d464d6f46f049c8c206400da5d9effcfb26ffaa3f901cae7e3ec40505f6a267b27ca3c3e332c110ce074006dd34ee87ec37ec541fb7
7
+ data.tar.gz: 928da3e0894dc85da016d3a35e1cc8c75614e5a863119a9aef40d6ca54d01c158f4b163aecf75942f93c4cb017baa506981c6db4996b2446a8c4b13eb620b430
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.3.0] - 2026-07-16
8
+
9
+ - `NbApiClient::RateLimiter` now enforces an exact sliding window via a Redis
10
+ Lua script (ZADD/ZREMRANGEBYSCORE/ZCARD on a sorted set) when
11
+ `configuration.cache_store` is Redis-backed and its client supports Lua
12
+ scripts (true for `ActiveSupport::Cache::RedisCacheStore`, or a raw
13
+ Redis/`ConnectionPool` passed in directly as `cache_store`). This is
14
+ detected automatically at runtime — no configuration change is needed. Every
15
+ other `cache_store` still falls back to the bucketed sliding-window-counter
16
+ approximation introduced in 0.2.0. `redis` is added as a development-only
17
+ dependency, used solely by the gem's own integration test.
18
+
19
+ ## [0.2.0] - 2026-07-16
20
+
21
+ - **Breaking:** Removed the `redis` and `connection_pool` gem dependencies, along with
22
+ `config.redis_url` and `config.rate_limit_pool_size`. `NbApiClient::RateLimiter` now
23
+ implements its sliding window (a bucketed sliding-window-counter algorithm) on top of
24
+ the existing `configuration.cache_store` (defaults to `Rails.cache`) instead of a
25
+ dedicated Redis connection pool and Lua script. Rate limiting is disabled entirely if
26
+ no `cache_store` is configured.
27
+
5
28
  ## [0.1.1] - 2026-07-16
6
29
 
7
30
  - 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. If `cache_store` is Redis-backed and its client supports Lua scripts, an exact sliding window is enforced atomically via a Lua script; otherwise a bucketed sliding-window-counter approximation is used, so any `ActiveSupport::Cache::Store` works.
9
9
 
10
10
  ## Installation
11
11
 
@@ -24,22 +24,22 @@ gem "nb_api_client", path: "gems/nb_api_client"
24
24
  ## Usage
25
25
 
26
26
  ```ruby
27
- NbApiClient::Request.call(nation, :get, "/api/v2/people/123")
28
- NbApiClient::Request.call(nation, :post, "/api/v2/people", {person: {email: "a@b.com"}})
27
+ NbApiClient::Request.call(nation, :get, "/api/v2/signups/123")
28
+ NbApiClient::Request.call(nation, :post, "/api/v2/signups", "data": {"type": "signups", "attributes": {{"email": "email@example.com"}}})
29
29
  ```
30
30
 
31
31
  ### The `nation` interface
32
32
 
33
33
  `nation` can be any object — it does not need to be an ActiveRecord model — that responds to:
34
34
 
35
- | Method | Returns |
36
- | --- | --- |
37
- | `slug` | A string uniquely identifying the account (used as a cache-key/log prefix). |
38
- | `active?` | Whether requests should be allowed (checked before every call, except `/oauth/token`). |
39
- | `url` | The base URL for the account, e.g. `"https://myorg.nationbuilder.com"`. |
40
- | `token` | The current OAuth access token, appended as a query param on every request. |
41
- | `refresh_oauth_token` | Refreshes the OAuth token in place; returns truthy on success, falsy on failure. |
42
- | `deauthorize` | Called after too many consecutive "unauthorized" responses (see `unauthorized_error_threshold` below). |
35
+ | Method | Returns |
36
+ | --------------------- | ------------------------------------------------------------------------------------------------------ |
37
+ | `slug` | A string uniquely identifying the account (used as a cache-key/log prefix). |
38
+ | `active?` | Whether requests should be allowed (checked before every call, except `/oauth/token`). |
39
+ | `url` | The base URL for the account, e.g. `"https://myorg.nationbuilder.com"`. |
40
+ | `token` | The current OAuth access token, appended as a query param on every request. |
41
+ | `refresh_oauth_token` | Refreshes the OAuth token in place; returns truthy on success, falsy on failure. |
42
+ | `deauthorize` | Called after too many consecutive "unauthorized" responses (see `unauthorized_error_threshold` below). |
43
43
 
44
44
  ### Configuration
45
45
 
@@ -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,17 +70,36 @@ 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. If `cache_store` is Redis-backed
75
+ # (e.g. Rails.cache is an ActiveSupport::Cache::RedisCacheStore, or you set
76
+ # cache_store to a raw Redis/ConnectionPool) and its client supports Lua
77
+ # scripts, this is enforced as an exact sliding window via a Lua script
78
+ # instead of the bucketed approximation used for other cache stores.
73
79
  config.rate_limit = 200 # requests
74
80
  config.rate_limit_window_seconds = 10 # per this many seconds
75
81
  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
82
  end
79
83
  ```
80
84
 
81
85
  All of the above have working defaults (matching NationBuilder's published limits with a safety margin), so a new app can start with zero configuration beyond `on_repeated_unauthorized`.
82
86
 
87
+ ### Responses
88
+
89
+ On success, `NbApiClient::Request.call` returns the raw [`HTTParty::Response`](https://www.rubydoc.info/gems/httparty/HTTParty/Response) object from the underlying call — it is not parsed or unwrapped for you. Useful methods on it include:
90
+
91
+ | Method | Returns |
92
+ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
93
+ | `parsed_response` | The JSON body parsed into a `Hash`/`Array`. |
94
+ | `code` | The HTTP status code, as an integer. |
95
+ | `headers` | An [`HTTParty::Response::Headers`](https://www.rubydoc.info/gems/httparty/HTTParty/Response/Headers) (delegates to `Net::HTTPHeader`). |
96
+ | `body` | The raw response body, as a string. |
97
+ | `success?` | Whether the response was a 2xx. |
98
+
99
+ `HTTParty::Response` also delegates most `Hash`/`Array` methods (`[]`, `fetch`, `each`, …) straight to `parsed_response`, so e.g. `response["data"]` or `response.fetch("code", "")` works directly on the response without calling `parsed_response` first. See the [HTTParty README](https://github.com/jnunemaker/httparty#readme) for the full API.
100
+
101
+ Under `Rails.env.test?` (with the default `short_circuit_in_test`, see below), `call` returns a plain `{}` instead of a real response.
102
+
83
103
  ### Error handling
84
104
 
85
105
  `NbApiClient::Request.call` raises:
@@ -99,7 +119,7 @@ bundle install
99
119
  bundle exec rake test
100
120
  ```
101
121
 
102
- The two Lua-script integration tests in `rate_limiter_test.rb` are skipped automatically if no Redis is reachable at `REDIS_URL`.
122
+ The Lua-script integration test in `rate_limiter_test.rb` is skipped automatically if no Redis is reachable at `REDIS_URL` (defaults to `redis://localhost:6379/0`).
103
123
 
104
124
  ## Releasing a new version
105
125
 
@@ -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,10 +1,25 @@
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). All callers share one window (configure
7
+ # via NbApiClient.configure).
8
+ #
9
+ # If cache_store is Redis-backed and its underlying client supports Lua
10
+ # scripts (i.e. responds to #eval — true for ActiveSupport::Cache::RedisCacheStore
11
+ # and for a bare Redis/ConnectionPool passed in directly), a true sliding
12
+ # window is enforced atomically via a Lua script (ZADD/ZREMRANGEBYSCORE/ZCARD
13
+ # on a sorted set).
14
+ #
15
+ # Otherwise, a "sliding window counter" algorithm is used instead: each window
16
+ # is divided into fixed buckets, and the previous bucket's count is weighted
17
+ # by how much of it still overlaps the sliding window. This approximates a
18
+ # true sliding window without requiring sorted-set support, so it works with
19
+ # any ActiveSupport::Cache::Store (memory, Memcached, ...).
20
+ #
21
+ # If no cache_store is configured, rate limiting is disabled entirely (every
22
+ # request is allowed through immediately).
8
23
  class RateLimiter
9
24
  class RateLimitExhausted < StandardError; end
10
25
 
@@ -66,10 +81,12 @@ module NbApiClient
66
81
  waited += sleep_time
67
82
  end
68
83
  end
69
- rescue ConnectionPool::TimeoutError, Redis::BaseError, RedisClient::Error => e
70
- # Fail open: if Redis is unavailable, allow the request through.
84
+ rescue RateLimitExhausted
85
+ raise
86
+ rescue StandardError => e
87
+ # Fail open: if the cache store is unavailable, allow the request through.
71
88
  # NbApiClient::Request's 429 handling acts as a safety net.
72
- config.logger&.warn("[NbApiClient::RateLimiter] Redis error, failing open: #{e.class} - #{e.message}")
89
+ config.logger&.warn("[NbApiClient::RateLimiter] cache store error, failing open: #{e.class} - #{e.message}")
73
90
  yield
74
91
  end
75
92
 
@@ -77,22 +94,72 @@ module NbApiClient
77
94
 
78
95
  # Attempts to acquire a slot in the sliding window.
79
96
  # Returns the new count (positive) on success, or -1 if the window is full.
97
+ # Always succeeds (returns 1) if no cache_store is configured, i.e. rate limiting is disabled.
80
98
  def try_acquire_slot
81
99
  config = NbApiClient.configuration
100
+ store = config.cache_store
101
+ return 1 unless store
102
+
103
+ redis = redis_client(store)
104
+ redis ? try_acquire_slot_via_lua(redis, config) : try_acquire_slot_via_cache_store(store, config)
105
+ end
106
+
107
+ def try_acquire_slot_via_lua(redis, config)
82
108
  now = Process.clock_gettime(Process::CLOCK_REALTIME)
83
109
  member = "#{now}:#{SecureRandom.hex(4)}"
110
+ argv = [config.rate_limit.to_s, config.rate_limit_window_seconds.to_s, now.to_s, member]
111
+
112
+ run = ->(conn) { conn.eval(ACQUIRE_SLOT_SCRIPT, keys: [@key], argv: argv) }
113
+ redis.respond_to?(:with) ? redis.with(&run) : run.call(redis)
114
+ end
115
+
116
+ # Approximates a sliding window via fixed buckets, weighting the previous
117
+ # bucket's count by how much of it still overlaps the sliding window.
118
+ def try_acquire_slot_via_cache_store(store, config)
119
+ window = config.rate_limit_window_seconds
120
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
121
+ current_bucket = (now / window).floor
122
+ previous_bucket = current_bucket - 1
123
+ elapsed_in_current = now - (current_bucket * window)
124
+ previous_weight = [(window - elapsed_in_current) / window, 0.0].max
84
125
 
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
- )
126
+ # Safety-net expiry so bucket keys don't live forever if traffic stops.
127
+ current_count = store.increment("#{@key}:#{current_bucket}", 1, expires_in: window * 2)
128
+ previous_count = store.read("#{@key}:#{previous_bucket}") || 0
129
+
130
+ weighted_count = current_count + (previous_count * previous_weight)
131
+
132
+ if weighted_count <= config.rate_limit
133
+ weighted_count
134
+ else
135
+ # Undo the increment so this rejected attempt doesn't count against the window.
136
+ store.decrement("#{@key}:#{current_bucket}", 1)
137
+ -1
91
138
  end
92
139
  end
93
140
 
94
- def redis(&block)
95
- NbApiClient.connection_pool.with(&block)
141
+ # Returns the Redis client (or ConnectionPool of them) backing `store`, if
142
+ # it -- or a checked-out connection from it -- supports Lua scripts (#eval).
143
+ # Covers ActiveSupport::Cache::RedisCacheStore (whose #redis may itself be a
144
+ # bare client or a ConnectionPool) as well as a bare Redis/ConnectionPool
145
+ # object passed in directly as cache_store. Returns nil otherwise, so the
146
+ # caller falls back to the generic cache_store algorithm. Memoized per
147
+ # RateLimiter instance since cache_store doesn't change mid-request.
148
+ def redis_client(store)
149
+ return @redis_client if defined?(@redis_client)
150
+
151
+ @redis_client = begin
152
+ candidate = store.respond_to?(:redis) ? store.redis : store
153
+ if candidate.nil?
154
+ nil
155
+ elsif candidate.respond_to?(:with)
156
+ candidate.with { |conn| conn.respond_to?(:eval) } ? candidate : nil
157
+ elsif candidate.respond_to?(:eval)
158
+ candidate
159
+ end
160
+ rescue StandardError
161
+ nil
162
+ end
96
163
  end
97
164
  end
98
165
  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.3.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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Flint
@@ -67,63 +67,49 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.0'
69
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
70
+ name: rake
85
71
  requirement: !ruby/object:Gem::Requirement
86
72
  requirements:
87
- - - ">="
73
+ - - "~>"
88
74
  - !ruby/object:Gem::Version
89
- version: '2.4'
90
- type: :runtime
75
+ version: '13.0'
76
+ type: :development
91
77
  prerelease: false
92
78
  version_requirements: !ruby/object:Gem::Requirement
93
79
  requirements:
94
- - - ">="
80
+ - - "~>"
95
81
  - !ruby/object:Gem::Version
96
- version: '2.4'
82
+ version: '13.0'
97
83
  - !ruby/object:Gem::Dependency
98
- name: rake
84
+ name: minitest
99
85
  requirement: !ruby/object:Gem::Requirement
100
86
  requirements:
101
87
  - - "~>"
102
88
  - !ruby/object:Gem::Version
103
- version: '13.0'
89
+ version: '5.0'
104
90
  type: :development
105
91
  prerelease: false
106
92
  version_requirements: !ruby/object:Gem::Requirement
107
93
  requirements:
108
94
  - - "~>"
109
95
  - !ruby/object:Gem::Version
110
- version: '13.0'
96
+ version: '5.0'
111
97
  - !ruby/object:Gem::Dependency
112
- name: minitest
98
+ name: redis
113
99
  requirement: !ruby/object:Gem::Requirement
114
100
  requirements:
115
- - - "~>"
101
+ - - ">="
116
102
  - !ruby/object:Gem::Version
117
103
  version: '5.0'
118
104
  type: :development
119
105
  prerelease: false
120
106
  version_requirements: !ruby/object:Gem::Requirement
121
107
  requirements:
122
- - - "~>"
108
+ - - ">="
123
109
  - !ruby/object:Gem::Version
124
110
  version: '5.0'
125
111
  description: Wraps NationBuilder API requests with OAuth token refresh, deauthorization
126
- handling, and a Redis-backed sliding-window rate limiter shared across processes.
112
+ handling, and a cache-store-backed sliding-window rate limiter shared across processes.
127
113
  email:
128
114
  executables: []
129
115
  extensions: []