Class: NbApiClient::RateLimiter

Inherits:
Object
  • Object
show all
Defined in:
lib/nb_api_client/rate_limiter.rb

Overview

A global (cross-process, cross-thread) distributed rate limiter backed by configuration.cache_store (the same store used for unauthorized-response tracking; defaults to Rails.cache). All callers share one window (configure via NbApiClient.configure).

If cache_store is Redis-backed and its underlying client supports Lua scripts (i.e. responds to #eval — true for ActiveSupport::Cache::RedisCacheStore and for a bare Redis/ConnectionPool passed in directly), a true sliding window is enforced atomically via a Lua script (ZADD/ZREMRANGEBYSCORE/ZCARD on a sorted set).

Otherwise, a "sliding window counter" algorithm is used instead: each window is divided into fixed buckets, and the previous bucket's count is weighted by how much of it still overlaps the sliding window. This approximates a true sliding window without requiring sorted-set support, so it works with any ActiveSupport::Cache::Store (memory, Memcached, ...).

If no cache_store is configured, rate limiting is disabled entirely (every request is allowed through immediately).

Defined Under Namespace

Classes: RateLimitExhausted

Constant Summary collapse

ACQUIRE_SLOT_SCRIPT =
  1. Remove all entries older than (now - window) to slide the window
  2. Count remaining entries in the set
  3. If under the limit, add a new entry (scored by timestamp) and return the new count
  4. If at/over the limit, return -1 (rejected)

The sorted set key is given a TTL of 2*window as a safety net to avoid unbounded memory growth if traffic stops entirely.

<<~LUA
  local key = KEYS[1]
  local limit = tonumber(ARGV[1])
  local window = tonumber(ARGV[2])
  local now = tonumber(ARGV[3])
  local member = ARGV[4]

  -- Prune entries outside the sliding window
  redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)

  local count = redis.call('ZCARD', key)

  if count < limit then
    redis.call('ZADD', key, now, member)
    -- Safety-net expiry so the key doesn't live forever if traffic stops
    redis.call('EXPIRE', key, window * 2)
    return count + 1
  else
    return -1
  end
LUA

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeRateLimiter

Returns a new instance of RateLimiter.



61
62
63
# File 'lib/nb_api_client/rate_limiter.rb', line 61

def initialize
  @key = "nb_rate_limit:window"
end

Class Method Details

.with_limit(&block) ⇒ Object

Blocks until a request slot is available, then yields to the caller. Raises RateLimitExhausted if the wait exceeds configuration.rate_limit_max_wait_seconds.



57
58
59
# File 'lib/nb_api_client/rate_limiter.rb', line 57

def self.with_limit(&block)
  new.with_limit(&block)
end

Instance Method Details

#with_limitObject



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/nb_api_client/rate_limiter.rb', line 65

def with_limit
  config = NbApiClient.configuration
  waited = 0.0

  loop do
    result = try_acquire_slot
    if result > 0
      return yield
    else
      if waited >= config.rate_limit_max_wait_seconds
        raise RateLimitExhausted, "Rate limit exhausted after waiting #{waited}s"
      end

      # Sleep with jitter to spread retries and avoid thundering herd
      sleep_time = config.rate_limit_poll_interval + rand(0.0..config.rate_limit_max_jitter)
      sleep(sleep_time)
      waited += sleep_time
    end
  end
rescue RateLimitExhausted
  raise
rescue StandardError => e
  # Fail open: if the cache store is unavailable, allow the request through.
  # NbApiClient::Request's 429 handling acts as a safety net.
  config.logger&.warn("[NbApiClient::RateLimiter] cache store error, failing open: #{e.class} - #{e.message}")
  yield
end