NationBuilder API Client

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 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.

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, and to build the account's base URL, e.g. "myorg" becomes "https://myorg.nationbuilder.com").
active? Whether requests should be allowed (checked before every call, except /oauth/token).
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).

If your model is an ActiveRecord model with token, refresh_token, and token_expires_at columns and supports #with_lock/#update!, just include the gem's mixin instead of writing refresh_oauth_token yourself:

class Nation < ApplicationRecord
  include NbApiClient::OAuthTokenRefresh
end

It also needs config.oauth_client_id/config.oauth_client_secret set (below; default from NB_CLIENT_ID/NB_CLIENT_SECRET env vars). Internally it POSTs to NationBuilder's /oauth/token endpoint via HTTParty (no oauth2 gem dependency), and uses with_lock plus an updated_at re-check to guard against multiple concurrent requests for the same nation refreshing the token at once.

If your model doesn't fit that shape (different column names, no with_lock, non-ActiveRecord storage), write your own refresh_oauth_token instead — it just needs to return truthy on success, falsy on failure.

Configuration

NB_API_CLIENT_CACHE_STORE = ActiveSupport::Cache::RedisCacheStore.new(
  url: ENV["REDIS_URL"],
  pool: {size: ENV.fetch("RATE_LIMITER_POOL_SIZE", 6).to_i, timeout: 5},
  namespace: "nb_api_client"
)

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.on_repeated_unauthorized = ->(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.unauthorized_error_threshold = 25
  config.unauthorized_error_window = 5 * 60

  # Only needed if you include NbApiClient::OAuthTokenRefresh. Default from
  # NB_CLIENT_ID/NB_CLIENT_SECRET env vars.
  config.oauth_client_id = ENV["NB_CLIENT_ID"]
  config.oauth_client_secret = ENV["NB_CLIENT_SECRET"]

  # 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

  # Example of a more advanced cache store, using its own Redis store with pooling.
#   config.cache_store = ActiveSupport::Cache::RedisCacheStore.new(
#   url: ENV["REDIS_URL"],
#   pool: {size: ENV.fetch("RATE_LIMITER_POOL_SIZE", 6).to_i, timeout: 5},
#   namespace: "nb_api_client"
# )

  # 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.
  # Refer to https://support.nationbuilder.com/en/articles/9868960-api-rate-limit-policy
  # for NationBuilder's rate limit policy.
  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 than max_rate_limit_retries times 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 within rate_limit_max_wait_seconds.
  • NbApiClient::Request::OAuthError — token refresh failed, or the API returned an unrecognized JSON error. (In 0.x this was OAuth2::Error; the gem no longer depends on oauth2 — see CHANGELOG.)

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

  1. Bump NbApiClient::VERSION in lib/nb_api_client/version.rb and add an entry to CHANGELOG.md.
  2. Commit, then tag the commit vX.Y.Z and push the tag.
  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 scoped to "push rubygem" for this gem.

License

MIT — see LICENSE.txt.