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

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. Defaults to
  # Rails.cache; must respond to #increment(key, amount, options).
  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 Redis across every process.
  config.rate_limit = 200               # requests
  config.rate_limit_window_seconds = 10 # per this many seconds
  config.rate_limit_max_wait_seconds = 120
  config.rate_limit_pool_size = 6        # Redis connection pool size
  config.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
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

The two Lua-script integration tests in rate_limiter_test.rb are skipped automatically if no Redis is reachable at REDIS_URL.

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.