nb_api_client 0.1.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +112 -0
- data/lib/nb_api_client/configuration.rb +62 -0
- data/lib/nb_api_client/rate_limiter.rb +98 -0
- data/lib/nb_api_client/request.rb +117 -0
- data/lib/nb_api_client/url_builder.rb +29 -0
- data/lib/nb_api_client/version.rb +5 -0
- data/lib/nb_api_client.rb +41 -0
- metadata +166 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2082f32bdb205caf63485893abe3199b480329fde2079592e38ba4b53b37660d
|
|
4
|
+
data.tar.gz: 793459a2c5f1271be72812d91cbf8d251795c8dffa5d73edb4d0d68047ca62e8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: efd0d26a0b2ac02fa0cd418b0ff27281fea5afa8122790d0ef36b83dc81216604d0395576b01207d4321de2cc39a446d03ee1b988ed1f5d18b85f79bbc5b26c2
|
|
7
|
+
data.tar.gz: d19ea9e67bb1689876e9d33d81c561ae452b59f41a5c736df151c4c7d90ef34a185a6b6c0602e1fcbfe496faf0fc90f15941fa99f4207c44112cf5c36c8cc3a8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.1] - 2026-07-16
|
|
6
|
+
|
|
7
|
+
- Publish to rubygems.org instead of GitLab's RubyGems registry (which is
|
|
8
|
+
feature-flagged off on this project) and fix `allowed_push_host`/CI
|
|
9
|
+
publish setup accordingly.
|
|
10
|
+
|
|
11
|
+
## [0.1.0] - 2026-07-16
|
|
12
|
+
|
|
13
|
+
- Initial release: `NbApiClient::Request` (rate-limited, token-refreshing HTTP
|
|
14
|
+
calls) and `NbApiClient::RateLimiter` (Redis-backed sliding-window rate
|
|
15
|
+
limiter).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Flint
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# NbApiClient
|
|
2
|
+
|
|
3
|
+
A rate-limited, token-refreshing HTTP client for the NationBuilder API, for use in Rails apps that integrate with NationBuilder on behalf of many OAuth-connected accounts ("nations").
|
|
4
|
+
|
|
5
|
+
It provides two pieces:
|
|
6
|
+
|
|
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.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
This gem is published to [rubygems.org](https://rubygems.org/gems/nb_api_client). Add to your Gemfile:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "nb_api_client"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
If you're working against an unreleased checkout instead, a path source still works:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "nb_api_client", path: "gems/nb_api_client"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
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"}})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### The `nation` interface
|
|
32
|
+
|
|
33
|
+
`nation` can be any object — it does not need to be an ActiveRecord model — that responds to:
|
|
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). |
|
|
43
|
+
|
|
44
|
+
### Configuration
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
NbApiClient.configure do |config|
|
|
48
|
+
# Called after `nation.deauthorize` when a nation has failed authorization
|
|
49
|
+
# too many times in a row. Use this to notify the account owner or schedule
|
|
50
|
+
# a job that retries the OAuth flow later.
|
|
51
|
+
config.on_repeated_unauthorized = ->(nation) {
|
|
52
|
+
ReauthorizeNationJob.set(queue: nation.sidekiq_queue).perform_in(1.hour, nation.id)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# How many "unauthorized" responses within `unauthorized_error_window`
|
|
56
|
+
# seconds before a nation is deauthorized. Defaults: 25 within 300s.
|
|
57
|
+
config.unauthorized_error_threshold = 25
|
|
58
|
+
config.unauthorized_error_window = 5 * 60
|
|
59
|
+
|
|
60
|
+
# Cache store used to count those unauthorized responses. Defaults to
|
|
61
|
+
# Rails.cache; must respond to #increment(key, amount, options).
|
|
62
|
+
config.cache_store = Rails.cache
|
|
63
|
+
|
|
64
|
+
# Logger for rate-limit/retry warnings. Defaults to Rails.logger.
|
|
65
|
+
config.logger = Rails.logger
|
|
66
|
+
|
|
67
|
+
# If true (the default), Request.call short-circuits to `{}` under
|
|
68
|
+
# Rails.env.test? instead of making a real HTTP call. Set to false if you'd
|
|
69
|
+
# rather stub HTTP at a lower level (e.g. WebMock/VCR) in your test suite.
|
|
70
|
+
config.short_circuit_in_test = true
|
|
71
|
+
|
|
72
|
+
# Sliding-window rate limit enforced via Redis across every process.
|
|
73
|
+
config.rate_limit = 200 # requests
|
|
74
|
+
config.rate_limit_window_seconds = 10 # per this many seconds
|
|
75
|
+
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
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
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
|
+
|
|
83
|
+
### Error handling
|
|
84
|
+
|
|
85
|
+
`NbApiClient::Request.call` raises:
|
|
86
|
+
|
|
87
|
+
- `NbApiClient::Request::UnauthorizedNationError` — the nation is inactive, or has been deauthorized after repeated auth failures.
|
|
88
|
+
- `NbApiClient::Request::RateLimitedError` — NationBuilder returned 429 more than `max_rate_limit_retries` times in a row.
|
|
89
|
+
- `NbApiClient::Request::InvalidContentType` — the API returned a non-JSON error body.
|
|
90
|
+
- `NbApiClient::RateLimiter::RateLimitExhausted` — the local rate limiter couldn't get a slot within `rate_limit_max_wait_seconds`.
|
|
91
|
+
- `OAuth2::Error` — token refresh failed, or the API returned an unrecognized JSON error.
|
|
92
|
+
|
|
93
|
+
These are ordinary Ruby exception classes, so they compose naturally with e.g. Sidekiq's `sidekiq_retry_in`/`sidekiq_retries_exhausted` hooks.
|
|
94
|
+
|
|
95
|
+
## Running the gem's own tests
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
bundle install
|
|
99
|
+
bundle exec rake test
|
|
100
|
+
```
|
|
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
|
+
## Releasing a new version
|
|
105
|
+
|
|
106
|
+
1. Bump `NbApiClient::VERSION` in `lib/nb_api_client/version.rb` and add an entry to `CHANGELOG.md`.
|
|
107
|
+
2. Commit, then tag the commit `vX.Y.Z` and push the tag.
|
|
108
|
+
3. In the resulting GitLab pipeline, manually run the `publish` job (it refuses to run unless the tag matches the gem version). This requires a protected, masked `GEM_HOST_API_KEY` CI/CD variable holding a [rubygems.org API key](https://guides.rubygems.org/publishing/#publishing-your-gem) scoped to "push rubygem" for this gem.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NbApiClient
|
|
4
|
+
class Configuration
|
|
5
|
+
# Cache store used to count repeated "unauthorized" responses for a nation.
|
|
6
|
+
# Must respond to #increment(key, amount, options) and #exist?/#write (if you
|
|
7
|
+
# also use the cache in on_repeated_unauthorized). Defaults to Rails.cache.
|
|
8
|
+
attr_accessor :cache_store
|
|
9
|
+
|
|
10
|
+
# Logger for rate-limit and retry warnings. Defaults to Rails.logger.
|
|
11
|
+
attr_accessor :logger
|
|
12
|
+
|
|
13
|
+
# Called with the nation whenever it has been deauthorized after too many
|
|
14
|
+
# consecutive "unauthorized" API responses. Use this to schedule re-authorization
|
|
15
|
+
# (e.g. a background job) or notify the account owner. Optional.
|
|
16
|
+
attr_accessor :on_repeated_unauthorized
|
|
17
|
+
|
|
18
|
+
# How many "unauthorized" responses within unauthorized_error_window before a
|
|
19
|
+
# nation is deauthorized.
|
|
20
|
+
attr_accessor :unauthorized_error_threshold
|
|
21
|
+
|
|
22
|
+
# Rolling window, in seconds, over which unauthorized_error_threshold is counted.
|
|
23
|
+
attr_accessor :unauthorized_error_window
|
|
24
|
+
|
|
25
|
+
# If true (the default), NbApiClient::Request#call returns {} immediately in
|
|
26
|
+
# Rails.env.test? instead of making a real HTTP request. This only applies when
|
|
27
|
+
# Request.call is invoked directly (not when it has been stubbed out in tests).
|
|
28
|
+
attr_accessor :short_circuit_in_test
|
|
29
|
+
|
|
30
|
+
attr_accessor :max_rate_limit_retries
|
|
31
|
+
attr_accessor :max_token_retries
|
|
32
|
+
|
|
33
|
+
# Sliding-window rate limit: at most `rate_limit` requests per `rate_limit_window_seconds`,
|
|
34
|
+
# enforced across all processes via Redis.
|
|
35
|
+
attr_accessor :rate_limit
|
|
36
|
+
attr_accessor :rate_limit_window_seconds
|
|
37
|
+
attr_accessor :rate_limit_max_wait_seconds
|
|
38
|
+
attr_accessor :rate_limit_poll_interval
|
|
39
|
+
attr_accessor :rate_limit_max_jitter
|
|
40
|
+
attr_accessor :rate_limit_pool_size
|
|
41
|
+
attr_accessor :redis_url
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@cache_store = (defined?(Rails) && Rails.respond_to?(:cache)) ? Rails.cache : nil
|
|
45
|
+
@logger = (defined?(Rails) && Rails.respond_to?(:logger)) ? Rails.logger : nil
|
|
46
|
+
@on_repeated_unauthorized = nil
|
|
47
|
+
@unauthorized_error_threshold = 25
|
|
48
|
+
@unauthorized_error_window = 5 * 60
|
|
49
|
+
@short_circuit_in_test = true
|
|
50
|
+
@max_rate_limit_retries = 3
|
|
51
|
+
@max_token_retries = 2
|
|
52
|
+
|
|
53
|
+
@rate_limit = ENV.fetch("API_RATE_LIMIT", 200).to_i
|
|
54
|
+
@rate_limit_window_seconds = ENV.fetch("API_RATE_WINDOW", 10).to_i
|
|
55
|
+
@rate_limit_max_wait_seconds = ENV.fetch("API_RATE_MAX_WAIT_SECONDS", 120).to_i
|
|
56
|
+
@rate_limit_poll_interval = 0.25
|
|
57
|
+
@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
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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).
|
|
8
|
+
class RateLimiter
|
|
9
|
+
class RateLimitExhausted < StandardError; end
|
|
10
|
+
|
|
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
|
+
# Blocks until a request slot is available, then yields to the caller.
|
|
41
|
+
# Raises RateLimitExhausted if the wait exceeds configuration.rate_limit_max_wait_seconds.
|
|
42
|
+
def self.with_limit(&block)
|
|
43
|
+
new.with_limit(&block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@key = "nb_rate_limit:window"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def with_limit
|
|
51
|
+
config = NbApiClient.configuration
|
|
52
|
+
waited = 0.0
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
result = try_acquire_slot
|
|
56
|
+
if result > 0
|
|
57
|
+
return yield
|
|
58
|
+
else
|
|
59
|
+
if waited >= config.rate_limit_max_wait_seconds
|
|
60
|
+
raise RateLimitExhausted, "Rate limit exhausted after waiting #{waited}s"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Sleep with jitter to spread retries and avoid thundering herd
|
|
64
|
+
sleep_time = config.rate_limit_poll_interval + rand(0.0..config.rate_limit_max_jitter)
|
|
65
|
+
sleep(sleep_time)
|
|
66
|
+
waited += sleep_time
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
rescue ConnectionPool::TimeoutError, Redis::BaseError, RedisClient::Error => e
|
|
70
|
+
# Fail open: if Redis is unavailable, allow the request through.
|
|
71
|
+
# NbApiClient::Request's 429 handling acts as a safety net.
|
|
72
|
+
config.logger&.warn("[NbApiClient::RateLimiter] Redis error, failing open: #{e.class} - #{e.message}")
|
|
73
|
+
yield
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Attempts to acquire a slot in the sliding window.
|
|
79
|
+
# Returns the new count (positive) on success, or -1 if the window is full.
|
|
80
|
+
def try_acquire_slot
|
|
81
|
+
config = NbApiClient.configuration
|
|
82
|
+
now = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
83
|
+
member = "#{now}:#{SecureRandom.hex(4)}"
|
|
84
|
+
|
|
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
|
|
93
|
+
|
|
94
|
+
def redis(&block)
|
|
95
|
+
NbApiClient.connection_pool.with(&block)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NbApiClient
|
|
4
|
+
# Makes an authenticated request against the NationBuilder API on behalf of a
|
|
5
|
+
# "nation" (any object satisfying the interface documented in the gem README),
|
|
6
|
+
# handling rate limiting, OAuth token refresh, and repeated-authorization-failure
|
|
7
|
+
# deauthorization.
|
|
8
|
+
class Request
|
|
9
|
+
class UnauthorizedNationError < StandardError
|
|
10
|
+
attr_reader :nation
|
|
11
|
+
|
|
12
|
+
def initialize(nation:)
|
|
13
|
+
@nation = nation
|
|
14
|
+
super("Unauthorized nation: #{nation}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class RateLimitedError < StandardError
|
|
19
|
+
attr_reader :retry_after
|
|
20
|
+
|
|
21
|
+
def initialize(retry_after:)
|
|
22
|
+
@retry_after = retry_after
|
|
23
|
+
super("Rate limited, retry after #{retry_after}s")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class InvalidContentType < StandardError
|
|
28
|
+
def initialize(nation:, body:, content_type:)
|
|
29
|
+
super("Invalid response content type (#{content_type}) for #{nation}: #{body}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.call(...)
|
|
34
|
+
new(...).call
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(nation, action, path, body = {})
|
|
38
|
+
@nation = nation
|
|
39
|
+
@action = action
|
|
40
|
+
@path = path
|
|
41
|
+
@url = UrlBuilder.new(@nation, @path).url
|
|
42
|
+
@body = body.is_a?(Hash) ? body.to_json : body
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def call
|
|
46
|
+
config = NbApiClient.configuration
|
|
47
|
+
return {} if config.short_circuit_in_test && defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
|
|
48
|
+
raise UnauthorizedNationError.new(nation: @nation.slug) unless @nation.active? || @path == "/oauth/token"
|
|
49
|
+
|
|
50
|
+
options = {timeout: 30, uri_adapter: Addressable::URI}
|
|
51
|
+
rate_limit_retries = 0
|
|
52
|
+
token_retries = 0
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
@response = RateLimiter.with_limit do
|
|
56
|
+
HTTParty.send(
|
|
57
|
+
@action,
|
|
58
|
+
@url,
|
|
59
|
+
body: @body,
|
|
60
|
+
headers: {Accept: "application/json", "Content-type": "application/json"},
|
|
61
|
+
**options
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return @response if @response.success?
|
|
66
|
+
|
|
67
|
+
if rate_limit_error?
|
|
68
|
+
wait_seconds = @response.headers["retry-after"].to_i + 1
|
|
69
|
+
config.logger&.warn("[NbApiClient::Request] Rate limited: #{@action.upcase} #{@path} nation=#{@nation.slug} retry_after=#{wait_seconds}s rate_limit_retries=#{rate_limit_retries}")
|
|
70
|
+
|
|
71
|
+
raise RateLimitedError.new(retry_after: wait_seconds) if (rate_limit_retries += 1) > config.max_rate_limit_retries
|
|
72
|
+
|
|
73
|
+
sleep wait_seconds
|
|
74
|
+
next
|
|
75
|
+
elsif expired_token_error?
|
|
76
|
+
raise OAuth2::Error.new(@response) if (token_retries += 1) > config.max_token_retries
|
|
77
|
+
raise OAuth2::Error.new(@response) unless @nation.refresh_oauth_token
|
|
78
|
+
|
|
79
|
+
next
|
|
80
|
+
elsif unauthorized_error?
|
|
81
|
+
raise OAuth2::Error.new(@response) if (token_retries += 1) > config.max_token_retries
|
|
82
|
+
|
|
83
|
+
if too_many_unauthorized_errors?(config)
|
|
84
|
+
@nation.deauthorize
|
|
85
|
+
config.on_repeated_unauthorized&.call(@nation)
|
|
86
|
+
raise UnauthorizedNationError.new(nation: @nation.slug)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@nation.refresh_oauth_token
|
|
90
|
+
next
|
|
91
|
+
elsif @response.content_type.in?(["application/json", "application/vnd.api+json"])
|
|
92
|
+
raise OAuth2::Error.new(@response)
|
|
93
|
+
else
|
|
94
|
+
raise InvalidContentType.new(nation: @nation.slug, body: @response.body, content_type: @response.content_type)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def too_many_unauthorized_errors?(config)
|
|
102
|
+
config.cache_store.increment("#{@nation.slug}_nb_unauthorized_errors", 1, expires_in: config.unauthorized_error_window) > config.unauthorized_error_threshold
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def rate_limit_error?
|
|
106
|
+
@response.code == 429 && @response.headers["retry-after"].present?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unauthorized_error?
|
|
110
|
+
@response.fetch("code", "") == "unauthorized"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def expired_token_error?
|
|
114
|
+
["token_expired", "invalid_grant"].include?(@response.fetch("code", ""))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NbApiClient
|
|
4
|
+
# Builds a fully-qualified NationBuilder API URL for a given nation and path,
|
|
5
|
+
# appending the nation's OAuth access token as a query parameter (unless the
|
|
6
|
+
# path is an OAuth endpoint, which is unauthenticated).
|
|
7
|
+
class UrlBuilder
|
|
8
|
+
def initialize(nation, path)
|
|
9
|
+
@nation = nation
|
|
10
|
+
@path = path
|
|
11
|
+
|
|
12
|
+
raise ArgumentError, "Path cannot be empty" if @path.blank?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def url
|
|
16
|
+
url_string = @path.start_with?("http") ? @path : @nation.url + @path
|
|
17
|
+
|
|
18
|
+
uri = URI.parse(url_string)
|
|
19
|
+
new_query_ar = if @path.start_with?("/oauth/")
|
|
20
|
+
URI.decode_www_form(String(uri.query))
|
|
21
|
+
else
|
|
22
|
+
URI.decode_www_form(String(uri.query)) << ["access_token", @nation.token]
|
|
23
|
+
end
|
|
24
|
+
uri.query = URI.encode_www_form(new_query_ar)
|
|
25
|
+
|
|
26
|
+
uri.to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
require "active_support/core_ext/object/blank"
|
|
8
|
+
require "active_support/core_ext/object/inclusion"
|
|
9
|
+
|
|
10
|
+
require "httparty"
|
|
11
|
+
require "addressable/uri"
|
|
12
|
+
require "oauth2"
|
|
13
|
+
require "redis"
|
|
14
|
+
require "connection_pool"
|
|
15
|
+
|
|
16
|
+
require_relative "nb_api_client/version"
|
|
17
|
+
require_relative "nb_api_client/configuration"
|
|
18
|
+
require_relative "nb_api_client/url_builder"
|
|
19
|
+
require_relative "nb_api_client/rate_limiter"
|
|
20
|
+
require_relative "nb_api_client/request"
|
|
21
|
+
|
|
22
|
+
module NbApiClient
|
|
23
|
+
class << self
|
|
24
|
+
def configuration
|
|
25
|
+
@configuration ||= Configuration.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configure
|
|
29
|
+
yield(configuration)
|
|
30
|
+
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
|
+
end
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nb_api_client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alex Flint
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: httparty
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.21'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.21'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: addressable
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.8'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.8'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: oauth2
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2.0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
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
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: rake
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '13.0'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '13.0'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: minitest
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - "~>"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '5.0'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - "~>"
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '5.0'
|
|
125
|
+
description: Wraps NationBuilder API requests with OAuth token refresh, deauthorization
|
|
126
|
+
handling, and a Redis-backed sliding-window rate limiter shared across processes.
|
|
127
|
+
email:
|
|
128
|
+
executables: []
|
|
129
|
+
extensions: []
|
|
130
|
+
extra_rdoc_files: []
|
|
131
|
+
files:
|
|
132
|
+
- CHANGELOG.md
|
|
133
|
+
- LICENSE.txt
|
|
134
|
+
- README.md
|
|
135
|
+
- lib/nb_api_client.rb
|
|
136
|
+
- lib/nb_api_client/configuration.rb
|
|
137
|
+
- lib/nb_api_client/rate_limiter.rb
|
|
138
|
+
- lib/nb_api_client/request.rb
|
|
139
|
+
- lib/nb_api_client/url_builder.rb
|
|
140
|
+
- lib/nb_api_client/version.rb
|
|
141
|
+
homepage: https://gitlab.com/acflint/nb_api_client
|
|
142
|
+
licenses:
|
|
143
|
+
- MIT
|
|
144
|
+
metadata:
|
|
145
|
+
source_code_uri: https://gitlab.com/acflint/nb_api_client
|
|
146
|
+
changelog_uri: https://gitlab.com/acflint/nb_api_client/-/blob/main/CHANGELOG.md
|
|
147
|
+
post_install_message:
|
|
148
|
+
rdoc_options: []
|
|
149
|
+
require_paths:
|
|
150
|
+
- lib
|
|
151
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
152
|
+
requirements:
|
|
153
|
+
- - ">="
|
|
154
|
+
- !ruby/object:Gem::Version
|
|
155
|
+
version: '3.2'
|
|
156
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
157
|
+
requirements:
|
|
158
|
+
- - ">="
|
|
159
|
+
- !ruby/object:Gem::Version
|
|
160
|
+
version: '0'
|
|
161
|
+
requirements: []
|
|
162
|
+
rubygems_version: 3.4.19
|
|
163
|
+
signing_key:
|
|
164
|
+
specification_version: 4
|
|
165
|
+
summary: Rate-limited, token-refreshing HTTP client for the NationBuilder API
|
|
166
|
+
test_files: []
|