Class: RubyReactor::RateLimit

Inherits:
Object
  • Object
show all
Defined in:
lib/ruby_reactor/rate_limit.rb

Overview

Distributed rate limiter (fixed-window counter, multi-window aware).

A ‘RateLimit` is configured with one or more (period, limit) tuples. `check_and_increment!` atomically verifies every window has headroom and, if so, increments all of them. The check uses a single Lua script so nothing slips through between read and write.

When any window is over-limit the call raises ‘ExceededError` carrying a `retry_after_seconds` hint (time until the tightest failing bucket rolls). The Sidekiq worker uses this hint to schedule a precise snooze.

Defined Under Namespace

Classes: ExceededError

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key_base, limits:) ⇒ RateLimit

Returns a new instance of RateLimit.

Parameters:

  • key_base (String)

    caller-provided key (e.g. “stripe:account_42”)

  • limits (Array<Hash>)

    each hash needs :period_seconds, :limit, and :name (used in the Redis bucket key and the error message)



61
62
63
64
# File 'lib/ruby_reactor/rate_limit.rb', line 61

def initialize(key_base, limits:)
  @key_base = key_base
  @limits = limits
end

Instance Attribute Details

#key_baseObject (readonly)

Returns the value of attribute key_base.



56
57
58
# File 'lib/ruby_reactor/rate_limit.rb', line 56

def key_base
  @key_base
end

#limitsObject (readonly)

Returns the value of attribute limits.



56
57
58
# File 'lib/ruby_reactor/rate_limit.rb', line 56

def limits
  @limits
end

Class Method Details

.normalize_specs(limit: nil, period: nil, limits: nil) ⇒ Object

Normalize the user-facing window args into the internal spec array that ‘RateLimit#initialize` expects. Shared by the reactor DSL (`with_rate_limit`) and the global registry (`config.rate_limits.register`).

Accepts either a single window (‘limit:` + `period:`) or a hash of windows (`limits:`). Returns Array<Hashlimit:, name:>.



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/ruby_reactor/rate_limit.rb', line 34

def self.normalize_specs(limit: nil, period: nil, limits: nil)
  if limits
    raise ArgumentError, "rate limit: use either :limits, or :limit + :period, not both" if limit || period

    limits.map do |period_key, limit_val|
      {
        period_seconds: RubyReactor::Period.period_seconds(period_key),
        limit: Integer(limit_val),
        name: period_key.to_s
      }
    end
  elsif limit && period
    [{
      period_seconds: RubyReactor::Period.period_seconds(period),
      limit: Integer(limit),
      name: period.to_s
    }]
  else
    raise ArgumentError, "rate limit requires :limit + :period, or :limits"
  end
end

Instance Method Details

#check_and_increment!Object

Raises:



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

def check_and_increment!
  now = Time.now.to_i
  keys = @limits.map { |spec| bucket_key(spec, now) }
  argv = [now]
  @limits.each do |spec|
    argv << spec[:period_seconds]
    argv << spec[:limit]
    argv << (spec[:period_seconds] * 2) # TTL: generous, auto-cleans stale buckets
  end

  allowed, retry_after, failed_index = adapter.rate_limit_check_and_increment(keys, argv)
  return true if allowed == 1

  failed = @limits[failed_index - 1]
  raise ExceededError.new(
    "Rate limit '#{@key_base}' exceeded (#{failed[:limit]}/#{failed[:name]}); " \
    "retry in #{retry_after}s",
    retry_after_seconds: retry_after,
    key_base: @key_base,
    limit: failed[:limit],
    period_seconds: failed[:period_seconds],
    period_name: failed[:name]
  )
end