NbApiClient
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").
It provides two pieces:
NbApiClient::Request— makes a single authenticated API call, transparently retrying on rate limits and refreshing expired OAuth tokens.NbApiClient::RateLimiter— a sliding-window rate limiter, backed by your configuredcache_store(defaults toRails.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 nocache_storeis configured, rate limiting is disabled entirely. Ifcache_storeis 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 anyActiveSupport::Cache::Storeworks.
Installation
This gem is published to rubygems.org. Add to your Gemfile:
gem "nb_api_client"
If you're working against an unreleased checkout instead, a path source still works:
gem "nb_api_client", path: "gems/nb_api_client"
Usage
NbApiClient::Request.call(nation, :get, "/api/v2/signups/123")
NbApiClient::Request.call(nation, :post, "/api/v2/signups", "data": {"type": "signups", "attributes": {{"email": "email@example.com"}}})
The nation interface
nation can be any object — it does not need to be an ActiveRecord model — that responds to:
| Method | Returns |
|---|---|
slug |
A string uniquely identifying the account (used as a cache-key/log prefix). |
active? |
Whether requests should be allowed (checked before every call, except /oauth/token). |
url |
The base URL for the account, e.g. "https://myorg.nationbuilder.com". |
token |
The current OAuth access token, appended as a query param on every request. |
refresh_oauth_token |
Refreshes the OAuth token in place; returns truthy on success, falsy on failure. |
deauthorize |
Called after too many consecutive "unauthorized" responses (see unauthorized_error_threshold below). |
Configuration
NbApiClient.configure do |config|
# Called after `nation.deauthorize` when a nation has failed authorization
# too many times in a row. Use this to notify the account owner or schedule
# a job that retries the OAuth flow later.
config. = ->(nation) {
ReauthorizeNationJob.set(queue: nation.sidekiq_queue).perform_in(1.hour, nation.id)
}
# How many "unauthorized" responses within `unauthorized_error_window`
# seconds before a nation is deauthorized. Defaults: 25 within 300s.
config. = 25
config. = 5 * 60
# Cache store used to count those unauthorized responses, and (below) to
# back the sliding-window rate limiter. Defaults to Rails.cache; must
# respond to #increment(key, amount, options), #decrement, and #read.
config.cache_store = Rails.cache
# Logger for rate-limit/retry warnings. Defaults to Rails.logger.
config.logger = Rails.logger
# If true (the default), Request.call short-circuits to `{}` under
# Rails.env.test? instead of making a real HTTP call. Set to false if you'd
# rather stub HTTP at a lower level (e.g. WebMock/VCR) in your test suite.
config.short_circuit_in_test = true
# Sliding-window rate limit enforced via `cache_store` across every process.
# Disabled entirely if `cache_store` is nil. If `cache_store` is Redis-backed
# (e.g. Rails.cache is an ActiveSupport::Cache::RedisCacheStore, or you set
# cache_store to a raw Redis/ConnectionPool) and its client supports Lua
# scripts, this is enforced as an exact sliding window via a Lua script
# instead of the bucketed approximation used for other cache stores.
config.rate_limit = 200 # requests
config.rate_limit_window_seconds = 10 # per this many seconds
config.rate_limit_max_wait_seconds = 120
end
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.
Responses
On success, NbApiClient::Request.call returns the raw HTTParty::Response object from the underlying call — it is not parsed or unwrapped for you. Useful methods on it include:
| Method | Returns |
|---|---|
parsed_response |
The JSON body parsed into a Hash/Array. |
code |
The HTTP status code, as an integer. |
headers |
An HTTParty::Response::Headers (delegates to Net::HTTPHeader). |
body |
The raw response body, as a string. |
success? |
Whether the response was a 2xx. |
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 for the full API.
Under Rails.env.test? (with the default short_circuit_in_test, see below), call returns a plain {} instead of a real response.
Error handling
NbApiClient::Request.call raises:
NbApiClient::Request::UnauthorizedNationError— the nation is inactive, or has been deauthorized after repeated auth failures.NbApiClient::Request::RateLimitedError— NationBuilder returned 429 more thanmax_rate_limit_retriestimes in a row.NbApiClient::Request::InvalidContentType— the API returned a non-JSON error body.NbApiClient::RateLimiter::RateLimitExhausted— the local rate limiter couldn't get a slot withinrate_limit_max_wait_seconds.OAuth2::Error— token refresh failed, or the API returned an unrecognized JSON error.
These are ordinary Ruby exception classes, so they compose naturally with e.g. Sidekiq's sidekiq_retry_in/sidekiq_retries_exhausted hooks.
Running the gem's own tests
bundle install
bundle exec rake test
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).
Releasing a new version
- Bump
NbApiClient::VERSIONinlib/nb_api_client/version.rband add an entry toCHANGELOG.md. - Commit, then tag the commit
vX.Y.Zand push the tag. - In the resulting GitLab pipeline, manually run the
publishjob (it refuses to run unless the tag matches the gem version). This requires a protected, maskedGEM_HOST_API_KEYCI/CD variable holding a rubygems.org API key scoped to "push rubygem" for this gem.
License
MIT — see LICENSE.txt.