Class: DiscordRDA::RateLimiter

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

Overview

Production-ready Rate Limiter for Discord REST API. Implements precise token bucket algorithm with async timer-based resets.

Defined Under Namespace

Classes: RateLimitInfo

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(logger: nil) ⇒ RateLimiter

Initialize rate limiter

Parameters:

  • logger (Logger) (defaults to: nil)

    Logger instance



28
29
30
31
32
33
34
35
36
# File 'lib/discord_rda/connection/rate_limiter.rb', line 28

def initialize(logger: nil)
  @logger = logger
  @limits = {}
  @waiters = Hash.new { |h, k| h[k] = [] }
  @mutex = Mutex.new
  @global_reset_at = nil
  @timers = {}
  @semaphore = Async::Semaphore.new(1)
end

Instance Attribute Details

#global_reset_atFloat (readonly)

Returns Global rate limit reset timestamp.

Returns:

  • (Float)

    Global rate limit reset timestamp



24
25
26
# File 'lib/discord_rda/connection/rate_limiter.rb', line 24

def global_reset_at
  @global_reset_at
end

#limitsHash<String, RateLimitInfo> (readonly)

Returns Rate limit info per route.

Returns:



15
16
17
# File 'lib/discord_rda/connection/rate_limiter.rb', line 15

def limits
  @limits
end

#loggerLogger (readonly)

Returns Logger instance.

Returns:

  • (Logger)

    Logger instance



21
22
23
# File 'lib/discord_rda/connection/rate_limiter.rb', line 21

def logger
  @logger
end

#waitersHash<String, Array<Async::Condition>] (readonly)

Returns ] Waiters per route.

Returns:

  • (Hash<String, Array<Async::Condition>])

    ] Waiters per route



18
19
20
# File 'lib/discord_rda/connection/rate_limiter.rb', line 18

def waiters
  @waiters
end

Instance Method Details

#acquire(route) ⇒ void

This method returns an undefined value.

Acquire permission to make a request with precise timing

Parameters:

  • route (String)

    Route identifier



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/discord_rda/connection/rate_limiter.rb', line 41

def acquire(route)
  # Check global rate limit first
  wait_for_global if @global_reset_at

  # Check route-specific limit with precise timing
  info = @mutex.synchronize { @limits[route] }
  return unless info

  if info.remaining <= 0 && info.reset_after > 0
    now = Time.now.to_f
    wait_time = [info.reset_after - (now - info.last_updated.to_f), 0].max

    if wait_time > 0
      @logger&.info('Rate limited, waiting', route: route, seconds: wait_time.round(3), bucket: info.bucket)
      precise_sleep(wait_time)
    end
  end
end

#bucket_id(route) ⇒ String?

Get bucket ID for a route

Parameters:

  • route (String)

    Route identifier

Returns:

  • (String, nil)

    Bucket ID



154
155
156
# File 'lib/discord_rda/connection/rate_limiter.rb', line 154

def bucket_id(route)
  @mutex.synchronize { @limits[route]&.bucket }
end

#clearvoid

This method returns an undefined value.

Clear all rate limits



182
183
184
185
186
187
188
189
# File 'lib/discord_rda/connection/rate_limiter.rb', line 182

def clear
  @mutex.synchronize do
    @limits.clear
    @timers.each_value(&:stop) if @timers
    @timers.clear
    @global_reset_at = nil
  end
end

#info(route) ⇒ RateLimitInfo?

Get rate limit info for a route

Parameters:

  • route (String)

    Route identifier

Returns:



110
111
112
# File 'lib/discord_rda/connection/rate_limiter.rb', line 110

def info(route)
  @mutex.synchronize { @limits[route] }
end

#limited?(route) ⇒ Boolean

Check if route is rate limited

Parameters:

  • route (String)

    Route identifier

Returns:

  • (Boolean)

    True if limited



117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/discord_rda/connection/rate_limiter.rb', line 117

def limited?(route)
  info = @mutex.synchronize { @limits[route] }
  return false unless info

  if info.remaining > 0
    false
  elsif info.reset_after <= 0
    false
  else
    now = Time.now.to_f
    elapsed = now - info.last_updated.to_f
    elapsed < info.reset_after
  end
end

#reset_time(route) ⇒ Time?

Get reset time for a route

Parameters:

  • route (String)

    Route identifier

Returns:

  • (Time, nil)

    Reset time



147
148
149
# File 'lib/discord_rda/connection/rate_limiter.rb', line 147

def reset_time(route)
  @mutex.synchronize { @limits[route]&.reset }
end

#statusHash

Get comprehensive status

Returns:

  • (Hash)

    Rate limiter status



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/discord_rda/connection/rate_limiter.rb', line 193

def status
  @mutex.synchronize do
    {
      global_limited: @global_reset_at && Time.now.to_f < @global_reset_at,
      global_reset_in: @global_reset_at ? [@global_reset_at - Time.now.to_f, 0].max : nil,
      routes_tracked: @limits.size,
      routes: @limits.transform_values do |info|
        {
          limit: info.limit,
          remaining: info.remaining,
          reset_after: info.reset_after,
          bucket: info.bucket,
          limited: limited?(@limits.key(info))
        }
      end
    }
  end
end

#time_until_reset(route) ⇒ Float?

Get time until reset for a route

Parameters:

  • route (String)

    Route identifier

Returns:

  • (Float, nil)

    Seconds until reset, or nil if not limited



135
136
137
138
139
140
141
142
# File 'lib/discord_rda/connection/rate_limiter.rb', line 135

def time_until_reset(route)
  info = @mutex.synchronize { @limits[route] }
  return nil unless info
  return nil if info.remaining > 0

  elapsed = Time.now.to_f - info.last_updated.to_f
  [info.reset_after - elapsed, 0].max
end

#update(route, response) ⇒ void

This method returns an undefined value.

Update rate limit info from response headers with precise timing

Parameters:

  • route (String)

    Route identifier

  • response (Protocol::HTTP::Response)

    HTTP response



64
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/discord_rda/connection/rate_limiter.rb', line 64

def update(route, response)
  headers = response.headers

  # Check for global rate limit
  global = headers['x-ratelimit-global']
  if global == 'true'
    retry_after = headers['retry-after']&.to_f || 1.0
    @global_reset_at = Time.now.to_f + retry_after
    @logger&.error('Global rate limit hit', reset_in: retry_after)
    schedule_global_reset(retry_after)
    notify_waiters(route)
    return
  end

  # Parse rate limit headers
  limit = headers['x-ratelimit-limit']&.to_i
  remaining = headers['x-ratelimit-remaining']&.to_i
  reset = headers['x-ratelimit-reset']&.to_f
  reset_after = headers['x-ratelimit-reset-after']&.to_f
  bucket = headers['x-ratelimit-bucket']

  return unless limit

  now = Time.now
  info = RateLimitInfo.new(
    limit: limit,
    remaining: remaining || 0,
    reset: reset ? Time.at(reset) : nil,
    reset_after: reset_after || 0,
    bucket: bucket,
    last_updated: now
  )

  @mutex.synchronize { @limits[route] = info }

  # Schedule precise reset timer if depleted
  if remaining && remaining <= 0 && reset_after && reset_after > 0
    schedule_route_reset(route, reset_after)
  end

  @logger&.debug('Rate limit updated', route: route, bucket: bucket, remaining: remaining, reset_after: reset_after&.round(3))
end

#wait_for_route(route) ⇒ void

This method returns an undefined value.

Wait for a route to be available (async-friendly)

Parameters:

  • route (String)

    Route identifier



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/discord_rda/connection/rate_limiter.rb', line 161

def wait_for_route(route)
  return unless limited?(route)

  wait_time = time_until_reset(route)
  return unless wait_time && wait_time > 0

  condition = Async::Condition.new
  @mutex.synchronize { @waiters[route] << condition }

  @logger&.debug('Waiting for route', route: route, seconds: wait_time.round(3))

  Async do |task|
    task.sleep(wait_time)
    condition.signal
  end

  condition.wait
end