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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +34 -12
- data/lib/nb_api_client/rate_limiter.rb +83 -7
- data/lib/nb_api_client/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03ba7132dde127d46367c5a7e9731862657586a3a16da516a951a108ab1b89ed
|
|
4
|
+
data.tar.gz: 4d6e1c174a2ff5a1efe821ab92cbad01902271a18bfdc6c87724c83d1e79ef43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
28
|
-
NbApiClient::Request.call(nation, :post, "/api/v2/
|
|
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
|
|
36
|
-
|
|
|
37
|
-
| `slug`
|
|
38
|
-
| `active?`
|
|
39
|
-
| `url`
|
|
40
|
-
| `token`
|
|
41
|
-
| `refresh_oauth_token` | Refreshes the OAuth token in place; returns truthy on success, falsy on failure.
|
|
42
|
-
| `deauthorize`
|
|
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).
|
|
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
|
|
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
|
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.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:
|