discord_rda 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +398 -0
- data/lib/discord_rda/bot.rb +842 -0
- data/lib/discord_rda/cache/configurable_cache.rb +283 -0
- data/lib/discord_rda/cache/entity_cache.rb +184 -0
- data/lib/discord_rda/cache/memory_store.rb +143 -0
- data/lib/discord_rda/cache/redis_store.rb +136 -0
- data/lib/discord_rda/cache/store.rb +56 -0
- data/lib/discord_rda/connection/gateway_client.rb +383 -0
- data/lib/discord_rda/connection/invalid_bucket.rb +205 -0
- data/lib/discord_rda/connection/rate_limiter.rb +280 -0
- data/lib/discord_rda/connection/request_queue.rb +340 -0
- data/lib/discord_rda/connection/reshard_manager.rb +328 -0
- data/lib/discord_rda/connection/rest_client.rb +316 -0
- data/lib/discord_rda/connection/rest_proxy.rb +165 -0
- data/lib/discord_rda/connection/scalable_rest_client.rb +526 -0
- data/lib/discord_rda/connection/shard_manager.rb +223 -0
- data/lib/discord_rda/core/async_runtime.rb +108 -0
- data/lib/discord_rda/core/configuration.rb +194 -0
- data/lib/discord_rda/core/logger.rb +188 -0
- data/lib/discord_rda/core/snowflake.rb +121 -0
- data/lib/discord_rda/entity/attachment.rb +88 -0
- data/lib/discord_rda/entity/base.rb +103 -0
- data/lib/discord_rda/entity/channel.rb +446 -0
- data/lib/discord_rda/entity/channel_builder.rb +280 -0
- data/lib/discord_rda/entity/color.rb +253 -0
- data/lib/discord_rda/entity/embed.rb +221 -0
- data/lib/discord_rda/entity/emoji.rb +89 -0
- data/lib/discord_rda/entity/factory.rb +99 -0
- data/lib/discord_rda/entity/guild.rb +619 -0
- data/lib/discord_rda/entity/member.rb +263 -0
- data/lib/discord_rda/entity/message.rb +405 -0
- data/lib/discord_rda/entity/message_builder.rb +369 -0
- data/lib/discord_rda/entity/role.rb +157 -0
- data/lib/discord_rda/entity/support.rb +294 -0
- data/lib/discord_rda/entity/user.rb +231 -0
- data/lib/discord_rda/entity/value_objects.rb +263 -0
- data/lib/discord_rda/event/auto_moderation.rb +294 -0
- data/lib/discord_rda/event/base.rb +986 -0
- data/lib/discord_rda/event/bus.rb +225 -0
- data/lib/discord_rda/event/scheduled_event.rb +257 -0
- data/lib/discord_rda/hot_reload_manager.rb +303 -0
- data/lib/discord_rda/interactions/application_command.rb +436 -0
- data/lib/discord_rda/interactions/command_system.rb +484 -0
- data/lib/discord_rda/interactions/components.rb +464 -0
- data/lib/discord_rda/interactions/interaction.rb +553 -0
- data/lib/discord_rda/plugin/analytics_plugin.rb +528 -0
- data/lib/discord_rda/plugin/base.rb +190 -0
- data/lib/discord_rda/plugin/registry.rb +126 -0
- data/lib/discord_rda/version.rb +5 -0
- data/lib/discord_rda.rb +70 -0
- metadata +302 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordRDA
|
|
4
|
+
# Production-ready Invalid Request Bucket - Prevents 1-hour Discord bans.
|
|
5
|
+
# Implements global request pausing when approaching invalid request limits.
|
|
6
|
+
#
|
|
7
|
+
class InvalidRequestBucket
|
|
8
|
+
# Default values per Discord's documentation
|
|
9
|
+
DEFAULT_LIMIT = 10_000
|
|
10
|
+
DEFAULT_INTERVAL = 10 * 60 * 1000 # 10 minutes in milliseconds
|
|
11
|
+
WARNING_THRESHOLD = 100 # Warn when remaining drops below this
|
|
12
|
+
PAUSE_THRESHOLD = 50 # Pause all requests when remaining drops below this
|
|
13
|
+
|
|
14
|
+
# @return [Integer] Maximum invalid requests allowed
|
|
15
|
+
attr_reader :limit
|
|
16
|
+
|
|
17
|
+
# @return [Integer] Time window in milliseconds
|
|
18
|
+
attr_reader :interval
|
|
19
|
+
|
|
20
|
+
# @return [Integer] Current remaining requests
|
|
21
|
+
attr_reader :remaining
|
|
22
|
+
|
|
23
|
+
# @return [Logger] Logger instance
|
|
24
|
+
attr_reader :logger
|
|
25
|
+
|
|
26
|
+
# @return [Boolean] Whether globally paused due to approaching limit
|
|
27
|
+
attr_reader :globally_paused
|
|
28
|
+
|
|
29
|
+
# Initialize invalid request bucket
|
|
30
|
+
# @param limit [Integer] Maximum invalid requests (default: 10000)
|
|
31
|
+
# @param interval [Integer] Time window in milliseconds (default: 600000)
|
|
32
|
+
# @param logger [Logger] Logger instance
|
|
33
|
+
def initialize(limit: DEFAULT_LIMIT, interval: DEFAULT_INTERVAL, logger: nil)
|
|
34
|
+
@limit = limit
|
|
35
|
+
@interval = interval
|
|
36
|
+
@remaining = limit
|
|
37
|
+
@logger = logger
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@frozen_at = nil
|
|
40
|
+
@reset_timer = nil
|
|
41
|
+
@globally_paused = false
|
|
42
|
+
@pause_condition = Async::Condition.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Wait until a request is allowed (not rate limited by invalid requests)
|
|
46
|
+
# Blocks if we've hit the invalid request limit or if globally paused
|
|
47
|
+
# @return [void]
|
|
48
|
+
def wait_until_request_available
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
# Wait if globally paused
|
|
51
|
+
while @globally_paused
|
|
52
|
+
@logger&.warn('Waiting: Globally paused due to invalid request limit')
|
|
53
|
+
@mutex.unlock
|
|
54
|
+
@pause_condition.wait
|
|
55
|
+
@mutex.lock
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if @remaining <= PAUSE_THRESHOLD && !@globally_paused
|
|
59
|
+
@globally_paused = true
|
|
60
|
+
@logger&.error('GLOBAL PAUSE ACTIVATED: Approaching invalid request limit!', remaining: @remaining)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if @remaining <= 0 && @frozen_at
|
|
64
|
+
now = Time.now.to_f * 1000
|
|
65
|
+
future = @frozen_at + @interval
|
|
66
|
+
wait_time = [(future - now) / 1000.0, 0].max
|
|
67
|
+
|
|
68
|
+
if wait_time > 0
|
|
69
|
+
@logger&.error('Invalid request bucket exhausted! Waiting to prevent 1-hour ban.', wait_seconds: wait_time.round(2))
|
|
70
|
+
@mutex.unlock
|
|
71
|
+
sleep(wait_time)
|
|
72
|
+
@mutex.lock
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if a request is allowed
|
|
79
|
+
# @return [Boolean] True if request can be made
|
|
80
|
+
def request_allowed?
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
return false if @globally_paused
|
|
83
|
+
return true if @remaining > 0
|
|
84
|
+
return true unless @frozen_at
|
|
85
|
+
|
|
86
|
+
now = Time.now.to_f * 1000
|
|
87
|
+
now >= (@frozen_at + @interval)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Handle a completed request response
|
|
92
|
+
# @param status [Integer] HTTP status code
|
|
93
|
+
# @return [void]
|
|
94
|
+
def handle_request(status)
|
|
95
|
+
# Only count 401, 403, 429, and 502 as invalid requests
|
|
96
|
+
return unless invalid_status?(status)
|
|
97
|
+
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@frozen_at ||= Time.now.to_f * 1000
|
|
100
|
+
@remaining -= 1
|
|
101
|
+
|
|
102
|
+
@logger&.debug('Invalid request counted', status: status, remaining: @remaining, limit: @limit)
|
|
103
|
+
|
|
104
|
+
# Schedule automatic reset
|
|
105
|
+
schedule_reset unless @reset_timer
|
|
106
|
+
|
|
107
|
+
# Check thresholds
|
|
108
|
+
if @remaining == WARNING_THRESHOLD
|
|
109
|
+
@logger&.warn('Approaching invalid request limit!', remaining: @remaining)
|
|
110
|
+
elsif @remaining == PAUSE_THRESHOLD
|
|
111
|
+
@globally_paused = true
|
|
112
|
+
@logger&.error('CRITICAL: Pausing all requests to prevent 1-hour Discord ban!', remaining: @remaining)
|
|
113
|
+
elsif @remaining <= 0
|
|
114
|
+
@logger&.error('INVALID REQUEST LIMIT REACHED! All requests blocked for 10 minutes.')
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Release global pause (call after interval or manual intervention)
|
|
120
|
+
# @return [void]
|
|
121
|
+
def release_pause
|
|
122
|
+
@mutex.synchronize do
|
|
123
|
+
was_paused = @globally_paused
|
|
124
|
+
@globally_paused = false
|
|
125
|
+
@logger&.info('Global pause released. Resuming normal request processing.') if was_paused
|
|
126
|
+
@pause_condition.signal
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reset the bucket (after interval has passed)
|
|
131
|
+
# @return [void]
|
|
132
|
+
def reset
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
old_remaining = @remaining
|
|
135
|
+
@remaining = @limit
|
|
136
|
+
@frozen_at = nil
|
|
137
|
+
@reset_timer = nil
|
|
138
|
+
was_paused = @globally_paused
|
|
139
|
+
@globally_paused = false
|
|
140
|
+
|
|
141
|
+
if old_remaining < WARNING_THRESHOLD
|
|
142
|
+
@logger&.info('Invalid request bucket reset', previous_remaining: old_remaining)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@pause_condition.signal if was_paused
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get current status with detailed information
|
|
150
|
+
# @return [Hash] Bucket status
|
|
151
|
+
def status
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
now = Time.now.to_f * 1000
|
|
154
|
+
reset_in = if @frozen_at && @remaining <= 0
|
|
155
|
+
[(@frozen_at + @interval - now) / 1000.0, 0].max
|
|
156
|
+
else
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
limit: @limit,
|
|
162
|
+
remaining: @remaining,
|
|
163
|
+
used: @limit - @remaining,
|
|
164
|
+
interval: @interval,
|
|
165
|
+
interval_minutes: @interval / 60000.0,
|
|
166
|
+
frozen_at: @frozen_at ? Time.at(@frozen_at / 1000.0) : nil,
|
|
167
|
+
reset_in_seconds: reset_in,
|
|
168
|
+
globally_paused: @globally_paused,
|
|
169
|
+
request_allowed: request_allowed?,
|
|
170
|
+
warning_threshold: WARNING_THRESHOLD,
|
|
171
|
+
pause_threshold: PAUSE_THRESHOLD,
|
|
172
|
+
healthy: @remaining > WARNING_THRESHOLD
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Check if bucket is healthy
|
|
178
|
+
# @return [Boolean] True if well above warning threshold
|
|
179
|
+
def healthy?
|
|
180
|
+
@mutex.synchronize { @remaining > WARNING_THRESHOLD }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Get percentage of remaining requests
|
|
184
|
+
# @return [Float] Percentage (0-100)
|
|
185
|
+
def health_percentage
|
|
186
|
+
@mutex.synchronize { (@remaining.to_f / @limit) * 100 }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def invalid_status?(status)
|
|
192
|
+
# Discord counts these as invalid requests
|
|
193
|
+
[401, 403, 429, 502].include?(status)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def schedule_reset
|
|
197
|
+
return if @reset_timer
|
|
198
|
+
|
|
199
|
+
@reset_timer = Async do |task|
|
|
200
|
+
task.sleep(@interval / 1000.0)
|
|
201
|
+
reset
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async/semaphore'
|
|
4
|
+
require 'async/condition'
|
|
5
|
+
|
|
6
|
+
module DiscordRDA
|
|
7
|
+
# Production-ready Rate Limiter for Discord REST API.
|
|
8
|
+
# Implements precise token bucket algorithm with async timer-based resets.
|
|
9
|
+
#
|
|
10
|
+
class RateLimiter
|
|
11
|
+
# Rate limit info structure
|
|
12
|
+
RateLimitInfo = Struct.new(:limit, :remaining, :reset, :reset_after, :bucket, :last_updated, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# @return [Hash<String, RateLimitInfo>] Rate limit info per route
|
|
15
|
+
attr_reader :limits
|
|
16
|
+
|
|
17
|
+
# @return [Hash<String, Array<Async::Condition>]>] Waiters per route
|
|
18
|
+
attr_reader :waiters
|
|
19
|
+
|
|
20
|
+
# @return [Logger] Logger instance
|
|
21
|
+
attr_reader :logger
|
|
22
|
+
|
|
23
|
+
# @return [Float] Global rate limit reset timestamp
|
|
24
|
+
attr_reader :global_reset_at
|
|
25
|
+
|
|
26
|
+
# Initialize rate limiter
|
|
27
|
+
# @param logger [Logger] Logger instance
|
|
28
|
+
def initialize(logger: nil)
|
|
29
|
+
@logger = logger
|
|
30
|
+
@limits = {}
|
|
31
|
+
@waiters = Hash.new { |h, k| h[k] = [] }
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@global_reset_at = nil
|
|
34
|
+
@timers = {}
|
|
35
|
+
@semaphore = Async::Semaphore.new(1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Acquire permission to make a request with precise timing
|
|
39
|
+
# @param route [String] Route identifier
|
|
40
|
+
# @return [void]
|
|
41
|
+
def acquire(route)
|
|
42
|
+
# Check global rate limit first
|
|
43
|
+
wait_for_global if @global_reset_at
|
|
44
|
+
|
|
45
|
+
# Check route-specific limit with precise timing
|
|
46
|
+
info = @mutex.synchronize { @limits[route] }
|
|
47
|
+
return unless info
|
|
48
|
+
|
|
49
|
+
if info.remaining <= 0 && info.reset_after > 0
|
|
50
|
+
now = Time.now.to_f
|
|
51
|
+
wait_time = [info.reset_after - (now - info.last_updated.to_f), 0].max
|
|
52
|
+
|
|
53
|
+
if wait_time > 0
|
|
54
|
+
@logger&.info('Rate limited, waiting', route: route, seconds: wait_time.round(3), bucket: info.bucket)
|
|
55
|
+
precise_sleep(wait_time)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Update rate limit info from response headers with precise timing
|
|
61
|
+
# @param route [String] Route identifier
|
|
62
|
+
# @param response [Protocol::HTTP::Response] HTTP response
|
|
63
|
+
# @return [void]
|
|
64
|
+
def update(route, response)
|
|
65
|
+
headers = response.headers
|
|
66
|
+
|
|
67
|
+
# Check for global rate limit
|
|
68
|
+
global = headers['x-ratelimit-global']
|
|
69
|
+
if global == 'true'
|
|
70
|
+
retry_after = headers['retry-after']&.to_f || 1.0
|
|
71
|
+
@global_reset_at = Time.now.to_f + retry_after
|
|
72
|
+
@logger&.error('Global rate limit hit', reset_in: retry_after)
|
|
73
|
+
schedule_global_reset(retry_after)
|
|
74
|
+
notify_waiters(route)
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parse rate limit headers
|
|
79
|
+
limit = headers['x-ratelimit-limit']&.to_i
|
|
80
|
+
remaining = headers['x-ratelimit-remaining']&.to_i
|
|
81
|
+
reset = headers['x-ratelimit-reset']&.to_f
|
|
82
|
+
reset_after = headers['x-ratelimit-reset-after']&.to_f
|
|
83
|
+
bucket = headers['x-ratelimit-bucket']
|
|
84
|
+
|
|
85
|
+
return unless limit
|
|
86
|
+
|
|
87
|
+
now = Time.now
|
|
88
|
+
info = RateLimitInfo.new(
|
|
89
|
+
limit: limit,
|
|
90
|
+
remaining: remaining || 0,
|
|
91
|
+
reset: reset ? Time.at(reset) : nil,
|
|
92
|
+
reset_after: reset_after || 0,
|
|
93
|
+
bucket: bucket,
|
|
94
|
+
last_updated: now
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@mutex.synchronize { @limits[route] = info }
|
|
98
|
+
|
|
99
|
+
# Schedule precise reset timer if depleted
|
|
100
|
+
if remaining && remaining <= 0 && reset_after && reset_after > 0
|
|
101
|
+
schedule_route_reset(route, reset_after)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@logger&.debug('Rate limit updated', route: route, bucket: bucket, remaining: remaining, reset_after: reset_after&.round(3))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get rate limit info for a route
|
|
108
|
+
# @param route [String] Route identifier
|
|
109
|
+
# @return [RateLimitInfo, nil] Rate limit info
|
|
110
|
+
def info(route)
|
|
111
|
+
@mutex.synchronize { @limits[route] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if route is rate limited
|
|
115
|
+
# @param route [String] Route identifier
|
|
116
|
+
# @return [Boolean] True if limited
|
|
117
|
+
def limited?(route)
|
|
118
|
+
info = @mutex.synchronize { @limits[route] }
|
|
119
|
+
return false unless info
|
|
120
|
+
|
|
121
|
+
if info.remaining > 0
|
|
122
|
+
false
|
|
123
|
+
elsif info.reset_after <= 0
|
|
124
|
+
false
|
|
125
|
+
else
|
|
126
|
+
now = Time.now.to_f
|
|
127
|
+
elapsed = now - info.last_updated.to_f
|
|
128
|
+
elapsed < info.reset_after
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get time until reset for a route
|
|
133
|
+
# @param route [String] Route identifier
|
|
134
|
+
# @return [Float, nil] Seconds until reset, or nil if not limited
|
|
135
|
+
def time_until_reset(route)
|
|
136
|
+
info = @mutex.synchronize { @limits[route] }
|
|
137
|
+
return nil unless info
|
|
138
|
+
return nil if info.remaining > 0
|
|
139
|
+
|
|
140
|
+
elapsed = Time.now.to_f - info.last_updated.to_f
|
|
141
|
+
[info.reset_after - elapsed, 0].max
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Get reset time for a route
|
|
145
|
+
# @param route [String] Route identifier
|
|
146
|
+
# @return [Time, nil] Reset time
|
|
147
|
+
def reset_time(route)
|
|
148
|
+
@mutex.synchronize { @limits[route]&.reset }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get bucket ID for a route
|
|
152
|
+
# @param route [String] Route identifier
|
|
153
|
+
# @return [String, nil] Bucket ID
|
|
154
|
+
def bucket_id(route)
|
|
155
|
+
@mutex.synchronize { @limits[route]&.bucket }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Wait for a route to be available (async-friendly)
|
|
159
|
+
# @param route [String] Route identifier
|
|
160
|
+
# @return [void]
|
|
161
|
+
def wait_for_route(route)
|
|
162
|
+
return unless limited?(route)
|
|
163
|
+
|
|
164
|
+
wait_time = time_until_reset(route)
|
|
165
|
+
return unless wait_time && wait_time > 0
|
|
166
|
+
|
|
167
|
+
condition = Async::Condition.new
|
|
168
|
+
@mutex.synchronize { @waiters[route] << condition }
|
|
169
|
+
|
|
170
|
+
@logger&.debug('Waiting for route', route: route, seconds: wait_time.round(3))
|
|
171
|
+
|
|
172
|
+
Async do |task|
|
|
173
|
+
task.sleep(wait_time)
|
|
174
|
+
condition.signal
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
condition.wait
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Clear all rate limits
|
|
181
|
+
# @return [void]
|
|
182
|
+
def clear
|
|
183
|
+
@mutex.synchronize do
|
|
184
|
+
@limits.clear
|
|
185
|
+
@timers.each_value(&:stop) if @timers
|
|
186
|
+
@timers.clear
|
|
187
|
+
@global_reset_at = nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Get comprehensive status
|
|
192
|
+
# @return [Hash] Rate limiter status
|
|
193
|
+
def status
|
|
194
|
+
@mutex.synchronize do
|
|
195
|
+
{
|
|
196
|
+
global_limited: @global_reset_at && Time.now.to_f < @global_reset_at,
|
|
197
|
+
global_reset_in: @global_reset_at ? [@global_reset_at - Time.now.to_f, 0].max : nil,
|
|
198
|
+
routes_tracked: @limits.size,
|
|
199
|
+
routes: @limits.transform_values do |info|
|
|
200
|
+
{
|
|
201
|
+
limit: info.limit,
|
|
202
|
+
remaining: info.remaining,
|
|
203
|
+
reset_after: info.reset_after,
|
|
204
|
+
bucket: info.bucket,
|
|
205
|
+
limited: limited?(@limits.key(info))
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def wait_for_global
|
|
215
|
+
return unless @global_reset_at
|
|
216
|
+
|
|
217
|
+
now = Time.now.to_f
|
|
218
|
+
if now < @global_reset_at
|
|
219
|
+
wait_time = @global_reset_at - now
|
|
220
|
+
@logger&.warn('Waiting for global rate limit', seconds: wait_time.round(3))
|
|
221
|
+
precise_sleep(wait_time)
|
|
222
|
+
end
|
|
223
|
+
@global_reset_at = nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def precise_sleep(seconds)
|
|
227
|
+
return if seconds <= 0
|
|
228
|
+
|
|
229
|
+
# Use Async sleep for async context, regular sleep otherwise
|
|
230
|
+
if defined?(Async::Task) && Async::Task.current?
|
|
231
|
+
Async::Task.current.sleep(seconds)
|
|
232
|
+
else
|
|
233
|
+
sleep(seconds)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def schedule_global_reset(seconds)
|
|
238
|
+
return if seconds <= 0
|
|
239
|
+
|
|
240
|
+
Async do |task|
|
|
241
|
+
task.sleep(seconds)
|
|
242
|
+
@global_reset_at = nil
|
|
243
|
+
notify_all_waiters
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def schedule_route_reset(route, seconds)
|
|
248
|
+
return if seconds <= 0
|
|
249
|
+
|
|
250
|
+
# Cancel existing timer
|
|
251
|
+
@timers[route]&.stop
|
|
252
|
+
|
|
253
|
+
@timers[route] = Async do |task|
|
|
254
|
+
task.sleep(seconds)
|
|
255
|
+
@mutex.synchronize do
|
|
256
|
+
if @limits[route]
|
|
257
|
+
@limits[route] = @limits[route].dup
|
|
258
|
+
@limits[route].remaining = @limits[route].limit
|
|
259
|
+
@limits[route].last_updated = Time.now
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
notify_waiters(route)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def notify_waiters(route)
|
|
267
|
+
waiters = @mutex.synchronize { @waiters.delete(route) || [] }
|
|
268
|
+
waiters.each(&:signal)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def notify_all_waiters
|
|
272
|
+
waiters = @mutex.synchronize do
|
|
273
|
+
all = @waiters.values.flatten
|
|
274
|
+
@waiters.clear
|
|
275
|
+
all
|
|
276
|
+
end
|
|
277
|
+
waiters.each(&:signal)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|