nb_api_client 0.2.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: ef855607df4fa9e26f513dd0ead54a8110b30baf30af94d21f10a8b11de06b45
4
- data.tar.gz: 9ddcd3bc22a1b7947fc74e0d789a498a5b252b980e0cefb92cf916afcdedb8af
3
+ metadata.gz: 03ba7132dde127d46367c5a7e9731862657586a3a16da516a951a108ab1b89ed
4
+ data.tar.gz: 4d6e1c174a2ff5a1efe821ab92cbad01902271a18bfdc6c87724c83d1e79ef43
5
5
  SHA512:
6
- metadata.gz: 027ae83f574dcfdccdfd1c0b138bc37f731ac24b5d7c0321874d7846115e99ff5d506ff896abb566770889024520a59825578b5933c5a6489994a5229f9f6735
7
- data.tar.gz: afe137de2bc248504c9a83f6b4e84e266de8f077556db836f8a6c3601636aa2aed094a912994479ebcf08ada7e694165c107ed886559df6c0b7916b61653857a
6
+ metadata.gz: 03efb68afa45a268187f5d464d6f46f049c8c206400da5d9effcfb26ffaa3f901cae7e3ec40505f6a267b27ca3c3e332c110ce074006dd34ee87ec37ec541fb7
7
+ data.tar.gz: 928da3e0894dc85da016d3a35e1cc8c75614e5a863119a9aef40d6ca54d01c158f4b163aecf75942f93c4cb017baa506981c6db4996b2446a8c4b13eb620b430
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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
+
5
19
  ## [0.2.0] - 2026-07-16
6
20
 
7
21
  - **Breaking:** Removed the `redis` and `connection_pool` gem dependencies, along with
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 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.
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
 
@@ -71,7 +71,11 @@ NbApiClient.configure do |config|
71
71
  config.short_circuit_in_test = true
72
72
 
73
73
  # Sliding-window rate limit enforced via `cache_store` across every process.
74
- # Disabled entirely if `cache_store` is nil.
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.
75
79
  config.rate_limit = 200 # requests
76
80
  config.rate_limit_window_seconds = 10 # per this many seconds
77
81
  config.rate_limit_max_wait_seconds = 120
@@ -80,6 +84,22 @@ end
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,6 +119,8 @@ bundle install
99
119
  bundle exec rake test
100
120
  ```
101
121
 
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`).
123
+
102
124
  ## Releasing a new version
103
125
 
104
126
  1. Bump `NbApiClient::VERSION` in `lib/nb_api_client/version.rb` and add an entry to `CHANGELOG.md`.
@@ -3,19 +3,55 @@
3
3
  module NbApiClient
4
4
  # A global (cross-process, cross-thread) distributed rate limiter backed by
5
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
6
+ # tracking; defaults to Rails.cache). All callers share one window (configure
12
7
  # via NbApiClient.configure).
13
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
+ #
14
21
  # If no cache_store is configured, rate limiting is disabled entirely (every
15
22
  # request is allowed through immediately).
16
23
  class RateLimiter
17
24
  class RateLimitExhausted < StandardError; end
18
25
 
26
+ # 1. Remove all entries older than (now - window) to slide the window
27
+ # 2. Count remaining entries in the set
28
+ # 3. If under the limit, add a new entry (scored by timestamp) and return the new count
29
+ # 4. If at/over the limit, return -1 (rejected)
30
+ #
31
+ # The sorted set key is given a TTL of 2*window as a safety net to avoid
32
+ # unbounded memory growth if traffic stops entirely.
33
+ ACQUIRE_SLOT_SCRIPT = <<~LUA
34
+ local key = KEYS[1]
35
+ local limit = tonumber(ARGV[1])
36
+ local window = tonumber(ARGV[2])
37
+ local now = tonumber(ARGV[3])
38
+ local member = ARGV[4]
39
+
40
+ -- Prune entries outside the sliding window
41
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
42
+
43
+ local count = redis.call('ZCARD', key)
44
+
45
+ if count < limit then
46
+ redis.call('ZADD', key, now, member)
47
+ -- Safety-net expiry so the key doesn't live forever if traffic stops
48
+ redis.call('EXPIRE', key, window * 2)
49
+ return count + 1
50
+ else
51
+ return -1
52
+ end
53
+ LUA
54
+
19
55
  # Blocks until a request slot is available, then yields to the caller.
20
56
  # Raises RateLimitExhausted if the wait exceeds configuration.rate_limit_max_wait_seconds.
21
57
  def self.with_limit(&block)
@@ -57,13 +93,29 @@ module NbApiClient
57
93
  private
58
94
 
59
95
  # Attempts to acquire a slot in the sliding window.
60
- # Returns the new (weighted) count (positive) on success, or -1 if the window is full.
96
+ # Returns the new count (positive) on success, or -1 if the window is full.
61
97
  # Always succeeds (returns 1) if no cache_store is configured, i.e. rate limiting is disabled.
62
98
  def try_acquire_slot
63
99
  config = NbApiClient.configuration
64
100
  store = config.cache_store
65
101
  return 1 unless store
66
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)
108
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
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)
67
119
  window = config.rate_limit_window_seconds
68
120
  now = Process.clock_gettime(Process::CLOCK_REALTIME)
69
121
  current_bucket = (now / window).floor
@@ -85,5 +137,29 @@ module NbApiClient
85
137
  -1
86
138
  end
87
139
  end
140
+
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
163
+ end
88
164
  end
89
165
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NbApiClient
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Flint
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '5.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: redis
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '5.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '5.0'
97
111
  description: Wraps NationBuilder API requests with OAuth token refresh, deauthorization
98
112
  handling, and a cache-store-backed sliding-window rate limiter shared across processes.
99
113
  email: