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

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/people/123")
NbApiClient::Request.call(nation, :post, "/api/v2/people", {person: {email: "a@b.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.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

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

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

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.