rito 0.1.0
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/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +182 -0
- data/lib/rito/client.rb +118 -0
- data/lib/rito/connection.rb +39 -0
- data/lib/rito/endpoints/account_v1.rb +45 -0
- data/lib/rito/endpoints/base.rb +35 -0
- data/lib/rito/endpoints/lol/challenges_v1.rb +31 -0
- data/lib/rito/endpoints/lol/champion_mastery_v4.rb +33 -0
- data/lib/rito/endpoints/lol/champion_v3.rb +17 -0
- data/lib/rito/endpoints/lol/clash_v1.rb +27 -0
- data/lib/rito/endpoints/lol/league_v4.rb +58 -0
- data/lib/rito/endpoints/lol/match_v5.rb +34 -0
- data/lib/rito/endpoints/lol/spectator_v5.rb +22 -0
- data/lib/rito/endpoints/lol/status_v4.rb +15 -0
- data/lib/rito/endpoints/lol/summoner_v4.rb +35 -0
- data/lib/rito/endpoints/lol/tournament_v5.rb +69 -0
- data/lib/rito/endpoints/lor.rb +95 -0
- data/lib/rito/endpoints/tft.rb +123 -0
- data/lib/rito/endpoints/valorant.rb +92 -0
- data/lib/rito/errors.rb +55 -0
- data/lib/rito/instrumentation.rb +17 -0
- data/lib/rito/middleware/authentication.rb +21 -0
- data/lib/rito/middleware/rate_limit_observe.rb +32 -0
- data/lib/rito/middleware/rate_limit_throttle.rb +58 -0
- data/lib/rito/middleware/riot_errors.rb +81 -0
- data/lib/rito/models/account.rb +38 -0
- data/lib/rito/models/lol.rb +80 -0
- data/lib/rito/models/match.rb +71 -0
- data/lib/rito/models/summoner.rb +23 -0
- data/lib/rito/rate_limiting/adaptive_limiter.rb +71 -0
- data/lib/rito/rate_limiting/bucket.rb +83 -0
- data/lib/rito/rate_limiting/header_parser.rb +53 -0
- data/lib/rito/rate_limiting/null_limiter.rb +11 -0
- data/lib/rito/rate_limiting/redis_limiter.rb +153 -0
- data/lib/rito/routing.rb +79 -0
- data/lib/rito/version.rb +5 -0
- data/lib/rito.rb +71 -0
- metadata +109 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module Middleware
|
|
5
|
+
# Raises the mapped Rito::Error for 4xx/5xx responses.
|
|
6
|
+
# Registered below RateLimitObserve so observation happens first.
|
|
7
|
+
class RiotErrors < Faraday::Middleware
|
|
8
|
+
ERROR_CLASSES = {
|
|
9
|
+
400 => Rito::BadRequest,
|
|
10
|
+
401 => Rito::Unauthorized,
|
|
11
|
+
403 => Rito::Forbidden,
|
|
12
|
+
404 => Rito::NotFound,
|
|
13
|
+
405 => Rito::MethodNotAllowed,
|
|
14
|
+
415 => Rito::UnsupportedMediaType
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(app, client:)
|
|
18
|
+
super(app)
|
|
19
|
+
@client = client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(env)
|
|
23
|
+
@app.call(env).on_complete { |completed| on_complete(completed) }
|
|
24
|
+
rescue Faraday::RetriableResponse => e
|
|
25
|
+
response = e.response
|
|
26
|
+
raise error_class_for(response.status).new(
|
|
27
|
+
"#{response.status} (after transport retries)",
|
|
28
|
+
response: response
|
|
29
|
+
)
|
|
30
|
+
rescue Faraday::ConnectionFailed => e
|
|
31
|
+
raise Rito::ConnectionError.new("#{e.class}: #{e.message}", wrapped: e)
|
|
32
|
+
rescue Faraday::TimeoutError, Timeout::Error => e
|
|
33
|
+
raise Rito::TimeoutError.new("#{e.class}: #{e.message}", wrapped: e)
|
|
34
|
+
rescue Faraday::SSLError => e
|
|
35
|
+
raise Rito::SSLError.new("#{e.class}: #{e.message}", wrapped: e)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def on_complete(env)
|
|
39
|
+
return if env.status.between?(200, 299)
|
|
40
|
+
# 5xx are retried by faraday-retry (registered above this middleware);
|
|
41
|
+
# raising here would abort the retry loop on the first attempt.
|
|
42
|
+
return if env.status >= 500
|
|
43
|
+
|
|
44
|
+
error_class = error_class_for(env.status)
|
|
45
|
+
return unless error_class
|
|
46
|
+
|
|
47
|
+
response = Faraday::Response.new(env)
|
|
48
|
+
if error_class == Rito::RateLimited
|
|
49
|
+
headers = env.response_headers
|
|
50
|
+
raise error_class.new(message(env),
|
|
51
|
+
retry_after: RateLimiting::HeaderParser.retry_after(headers),
|
|
52
|
+
limit_type: RateLimiting::HeaderParser.limit_type(headers),
|
|
53
|
+
response: response)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
raise error_class.new(message(env), response: response)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def error_class_for(status)
|
|
62
|
+
if status == 429
|
|
63
|
+
Rito::RateLimited
|
|
64
|
+
elsif status.between?(500, 599)
|
|
65
|
+
status == 503 ? Rito::ServiceUnavailable : Rito::ServerError
|
|
66
|
+
else
|
|
67
|
+
ERROR_CLASSES[status]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def message(env)
|
|
72
|
+
parsed = env.response_body
|
|
73
|
+
parsed = JSON.parse(parsed) if parsed.is_a?(String) && !parsed.empty?
|
|
74
|
+
detail = parsed.is_a?(Hash) ? (parsed['message'] || parsed.dig('status', 'message')) : nil
|
|
75
|
+
|
|
76
|
+
base = "#{env.status} #{env.method.to_s.upcase} #{env.url}"
|
|
77
|
+
detail ? "#{base}: #{detail}" : base
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module Models
|
|
5
|
+
Account = Data.define(:puuid, :game_name, :tag_line, :raw) do
|
|
6
|
+
def self.from_api(hash)
|
|
7
|
+
new(
|
|
8
|
+
puuid: hash['puuid'],
|
|
9
|
+
game_name: hash['gameName'],
|
|
10
|
+
tag_line: hash['tagLine'],
|
|
11
|
+
raw: hash.freeze
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
ActiveShard = Data.define(:puuid, :game, :active_shard, :raw) do
|
|
17
|
+
def self.from_api(hash)
|
|
18
|
+
new(
|
|
19
|
+
puuid: hash['puuid'],
|
|
20
|
+
game: hash['game'],
|
|
21
|
+
active_shard: hash['activeShard'],
|
|
22
|
+
raw: hash.freeze
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
AccountRegion = Data.define(:puuid, :game, :region, :raw) do
|
|
28
|
+
def self.from_api(hash)
|
|
29
|
+
new(
|
|
30
|
+
puuid: hash['puuid'],
|
|
31
|
+
game: hash['game'],
|
|
32
|
+
region: hash['region'],
|
|
33
|
+
raw: hash.freeze
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module Models
|
|
5
|
+
ChampionMastery = Data.define(
|
|
6
|
+
:puuid, :champion_id, :champion_level, :champion_points,
|
|
7
|
+
:champion_points_until_next_level, :champion_points_since_last_level,
|
|
8
|
+
:last_play_time, :tokens_earned, :raw
|
|
9
|
+
) do
|
|
10
|
+
def self.from_api(hash)
|
|
11
|
+
new(
|
|
12
|
+
puuid: hash['puuid'],
|
|
13
|
+
champion_id: hash['championId'],
|
|
14
|
+
champion_level: hash['championLevel'],
|
|
15
|
+
champion_points: hash['championPoints'],
|
|
16
|
+
champion_points_until_next_level: hash['championPointsUntilNextLevel'],
|
|
17
|
+
champion_points_since_last_level: hash['championPointsSinceLastLevel'],
|
|
18
|
+
last_play_time: hash['lastPlayTime'],
|
|
19
|
+
tokens_earned: hash['tokensEarned'],
|
|
20
|
+
raw: hash.freeze
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
ChampionInfo = Data.define(:sr, :newplayer, :raw) do
|
|
26
|
+
def self.from_api(hash)
|
|
27
|
+
new(
|
|
28
|
+
sr: hash['sr'],
|
|
29
|
+
newplayer: hash['newplayer'],
|
|
30
|
+
raw: hash.freeze
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
LeagueEntry = Data.define(
|
|
36
|
+
:puuid, :summoner_id, :queue_type, :tier, :rank, :league_id,
|
|
37
|
+
:league_points, :wins, :losses, :veteran, :fresh_blood, :hot_streak,
|
|
38
|
+
:inactive, :raw
|
|
39
|
+
) do
|
|
40
|
+
def self.from_api(hash)
|
|
41
|
+
new(
|
|
42
|
+
puuid: hash['puuid'],
|
|
43
|
+
summoner_id: hash['summonerId'],
|
|
44
|
+
queue_type: hash['queueType'],
|
|
45
|
+
tier: hash['tier'],
|
|
46
|
+
rank: hash['rank'],
|
|
47
|
+
league_id: hash['leagueId'],
|
|
48
|
+
league_points: hash['leaguePoints'],
|
|
49
|
+
wins: hash['wins'],
|
|
50
|
+
losses: hash['losses'],
|
|
51
|
+
veteran: hash['veteran'],
|
|
52
|
+
fresh_blood: hash['freshBlood'],
|
|
53
|
+
hot_streak: hash['hotStreak'],
|
|
54
|
+
inactive: hash['inactive'],
|
|
55
|
+
raw: hash.freeze
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def winrate
|
|
60
|
+
total = wins.to_i + losses.to_i
|
|
61
|
+
return nil if total.zero?
|
|
62
|
+
|
|
63
|
+
(wins.to_f / total * 100).round(2)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
LeagueList = Data.define(:league_id, :tier, :name, :queue, :entries, :raw) do
|
|
68
|
+
def self.from_api(hash)
|
|
69
|
+
new(
|
|
70
|
+
league_id: hash['leagueId'],
|
|
71
|
+
tier: hash['tier'],
|
|
72
|
+
name: hash['name'],
|
|
73
|
+
queue: hash['queue'],
|
|
74
|
+
entries: hash['entries'].to_a.freeze,
|
|
75
|
+
raw: hash.freeze
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module Models
|
|
5
|
+
Match = Data.define(:metadata, :info, :raw) do
|
|
6
|
+
def match_id
|
|
7
|
+
metadata&.match_id
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.from_api(hash)
|
|
11
|
+
new(
|
|
12
|
+
metadata: MatchMetadata.from_api(hash['metadata']),
|
|
13
|
+
info: MatchInfo.from_api(hash['info']),
|
|
14
|
+
raw: hash.freeze
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
MatchMetadata = Data.define(:match_id, :participants, :raw) do
|
|
20
|
+
def self.from_api(hash)
|
|
21
|
+
return nil if hash.nil?
|
|
22
|
+
|
|
23
|
+
new(
|
|
24
|
+
match_id: hash['matchId'],
|
|
25
|
+
participants: hash['participants'],
|
|
26
|
+
raw: hash.freeze
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
MatchInfo = Data.define(:game_creation, :game_duration, :game_end_timestamp,
|
|
32
|
+
:game_id, :game_mode, :game_start_timestamp, :game_type,
|
|
33
|
+
:game_version, :map_id, :participants, :queue_id, :raw) do
|
|
34
|
+
def self.from_api(hash)
|
|
35
|
+
return nil if hash.nil?
|
|
36
|
+
|
|
37
|
+
new(
|
|
38
|
+
game_creation: hash['gameCreation'],
|
|
39
|
+
game_duration: hash['gameDuration'],
|
|
40
|
+
game_end_timestamp: hash['gameEndTimestamp'],
|
|
41
|
+
game_id: hash['gameId'],
|
|
42
|
+
game_mode: hash['gameMode'],
|
|
43
|
+
game_start_timestamp: hash['gameStartTimestamp'],
|
|
44
|
+
game_type: hash['gameType'],
|
|
45
|
+
game_version: hash['gameVersion'],
|
|
46
|
+
map_id: hash['mapId'],
|
|
47
|
+
participants: hash['participants']&.map { |p| MatchParticipant.from_api(p) },
|
|
48
|
+
queue_id: hash['queueId'],
|
|
49
|
+
raw: hash.freeze
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
MatchParticipant = Data.define(:champion_name, :kills, :deaths, :assists,
|
|
55
|
+
:puuid, :summoner_name, :team_id, :win, :raw) do
|
|
56
|
+
def self.from_api(hash)
|
|
57
|
+
new(
|
|
58
|
+
champion_name: hash['championName'],
|
|
59
|
+
kills: hash['kills'],
|
|
60
|
+
deaths: hash['deaths'],
|
|
61
|
+
assists: hash['assists'],
|
|
62
|
+
puuid: hash['puuid'],
|
|
63
|
+
summoner_name: hash['summonerName'],
|
|
64
|
+
team_id: hash['teamId'],
|
|
65
|
+
win: hash['win'],
|
|
66
|
+
raw: hash.freeze
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module Models
|
|
5
|
+
Summoner = Data.define(
|
|
6
|
+
:id, :account_id, :puuid, :name, :profile_icon_id,
|
|
7
|
+
:revision_date, :summoner_level, :raw
|
|
8
|
+
) do
|
|
9
|
+
def self.from_api(hash)
|
|
10
|
+
new(
|
|
11
|
+
id: hash['id'],
|
|
12
|
+
account_id: hash['accountId'],
|
|
13
|
+
puuid: hash['puuid'],
|
|
14
|
+
name: hash['name'],
|
|
15
|
+
profile_icon_id: hash['profileIconId'],
|
|
16
|
+
revision_date: hash['revisionDate'],
|
|
17
|
+
summoner_level: hash['summonerLevel'],
|
|
18
|
+
raw: hash.freeze
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RateLimiting
|
|
5
|
+
# Pre-emptive limiter driven entirely by rate limit headers.
|
|
6
|
+
# Buckets are keyed per (api key, region, endpoint) so multiple keys
|
|
7
|
+
# and regions never share state.
|
|
8
|
+
class AdaptiveLimiter
|
|
9
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
10
|
+
sleeper: Kernel.method(:sleep))
|
|
11
|
+
@buckets = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@clock = clock
|
|
14
|
+
@sleeper = sleeper
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def acquire!(key:, region:, bucket:)
|
|
18
|
+
app_bucket(key, region).wait_until_allowed!
|
|
19
|
+
method_bucket(key, region, bucket).wait_until_allowed!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def observe!(response, key:, region:, bucket:)
|
|
23
|
+
headers = response.headers
|
|
24
|
+
status = response.status
|
|
25
|
+
|
|
26
|
+
app = app_bucket(key, region)
|
|
27
|
+
method = method_bucket(key, region, bucket)
|
|
28
|
+
|
|
29
|
+
app.apply!(limits: HeaderParser.app_limits(headers), counts: HeaderParser.app_counts(headers))
|
|
30
|
+
method.apply!(limits: HeaderParser.method_limits(headers), counts: HeaderParser.method_counts(headers))
|
|
31
|
+
|
|
32
|
+
return unless status == 429
|
|
33
|
+
|
|
34
|
+
retry_after = HeaderParser.retry_after(headers)
|
|
35
|
+
case HeaderParser.limit_type(headers)
|
|
36
|
+
when :application
|
|
37
|
+
app.limit_block!(retry_after)
|
|
38
|
+
when :method, :service
|
|
39
|
+
method.limit_block!(retry_after)
|
|
40
|
+
else
|
|
41
|
+
method.limit_block!(retry_after)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bucket_blocked?(key:, region:, bucket: nil, scope: :app)
|
|
46
|
+
bucket_for_scope(key, region, bucket, scope).blocked?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def app_bucket(key, region)
|
|
52
|
+
bucket_for('app', key, region, nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def method_bucket(key, region, bucket)
|
|
56
|
+
bucket_for('method', key, region, bucket)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def bucket_for_scope(key, region, bucket, scope)
|
|
60
|
+
scope == :app ? app_bucket(key, region) : method_bucket(key, region, bucket)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def bucket_for(scope, key, region, endpoint)
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
@buckets[[scope, key, Routing.normalize(region), endpoint]] ||=
|
|
66
|
+
Bucket.new(clock: @clock, sleeper: @sleeper)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RateLimiting
|
|
5
|
+
# Tracks windows learned from X-App-Rate-Limit / X-Method-Rate-Limit headers
|
|
6
|
+
# and blocks callers before limits are hit.
|
|
7
|
+
class Bucket
|
|
8
|
+
DEFAULT_BACKOFF = 1
|
|
9
|
+
MAX_BACKOFF = 32
|
|
10
|
+
JITTER = 0.5
|
|
11
|
+
|
|
12
|
+
def initialize(clock:, sleeper: Kernel.method(:sleep))
|
|
13
|
+
@clock = clock
|
|
14
|
+
@sleeper = sleeper
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@windows = []
|
|
17
|
+
@blocked_until = nil
|
|
18
|
+
@limited_streak = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def apply!(limits:, counts:)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
now = @clock.call
|
|
24
|
+
if limits.any?
|
|
25
|
+
counts_by_window = counts.to_h { |count, window| [window, count] }
|
|
26
|
+
@windows = limits.map do |limit, window|
|
|
27
|
+
previous = @windows.find { |w| w[:window] == window && w[:reset_at] > now }
|
|
28
|
+
{
|
|
29
|
+
limit: limit,
|
|
30
|
+
window: window,
|
|
31
|
+
reset_at: now + window,
|
|
32
|
+
count: counts_by_window.fetch(window) { previous&.fetch(:count) || 0 }
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
@limited_streak = 0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Blocks the bucket. When retry_after is nil (missing Retry-After header),
|
|
41
|
+
# falls back to exponential backoff with jitter based on the streak of
|
|
42
|
+
# consecutive 429s.
|
|
43
|
+
def limit_block!(retry_after)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
now = @clock.call
|
|
46
|
+
@limited_streak += 1
|
|
47
|
+
delay = retry_after || [@limited_streak * DEFAULT_BACKOFF, MAX_BACKOFF].min
|
|
48
|
+
delay += rand * JITTER
|
|
49
|
+
until_time = now + delay
|
|
50
|
+
@blocked_until = until_time if @blocked_until.nil? || @blocked_until < until_time
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def wait_until_allowed!
|
|
55
|
+
loop do
|
|
56
|
+
delay = nil
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
now = @clock.call
|
|
59
|
+
if @blocked_until && @blocked_until > now
|
|
60
|
+
delay = @blocked_until - now
|
|
61
|
+
else
|
|
62
|
+
@windows.reject! { |w| w[:reset_at] <= now }
|
|
63
|
+
violating = @windows.find { |w| w[:count] >= w[:limit] }
|
|
64
|
+
if violating
|
|
65
|
+
delay = violating[:reset_at] - now
|
|
66
|
+
else
|
|
67
|
+
@windows.each { |w| w[:count] += 1 }
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
@sleeper.call(delay + 0.001)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def blocked?
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@blocked_until && @blocked_until > @clock.call
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RateLimiting
|
|
5
|
+
module HeaderParser
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse_pairs(value)
|
|
9
|
+
return [] if value.nil?
|
|
10
|
+
|
|
11
|
+
value.split(',').filter_map do |part|
|
|
12
|
+
limit, window = part.strip.split(':', 2)
|
|
13
|
+
next if limit.nil? || window.nil?
|
|
14
|
+
|
|
15
|
+
[Integer(limit), Integer(window)]
|
|
16
|
+
rescue ArgumentError
|
|
17
|
+
next
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def app_limits(headers)
|
|
22
|
+
parse_pairs(headers['X-App-Rate-Limit'])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def app_counts(headers)
|
|
26
|
+
parse_pairs(headers['X-App-Rate-Limit-Count'])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def method_limits(headers)
|
|
30
|
+
parse_pairs(headers['X-Method-Rate-Limit'])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def method_counts(headers)
|
|
34
|
+
parse_pairs(headers['X-Method-Rate-Limit-Count'])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def retry_after(headers)
|
|
38
|
+
value = headers['Retry-After']
|
|
39
|
+
Integer(value) if value&.match?(/\A\d+\z/)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
LIMIT_TYPES = {
|
|
43
|
+
'application' => :application,
|
|
44
|
+
'method' => :method,
|
|
45
|
+
'service' => :service
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
def limit_type(headers)
|
|
49
|
+
LIMIT_TYPES[headers['X-Rate-Limit-Type']]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Rito
|
|
6
|
+
module RateLimiting
|
|
7
|
+
# Distributed rate limiter backed by Redis for multi-process / multi-host
|
|
8
|
+
# applications. Soft dependency: requires the `redis` gem at instantiation,
|
|
9
|
+
# not at load time.
|
|
10
|
+
#
|
|
11
|
+
# Fixed windows with atomic check-and-increment (Lua), counters synced from
|
|
12
|
+
# authoritative X-*-Count headers, and per-scope block keys for Retry-After.
|
|
13
|
+
class RedisLimiter
|
|
14
|
+
class DependencyMissing < Error; end
|
|
15
|
+
|
|
16
|
+
LIMITS_TTL = 600
|
|
17
|
+
|
|
18
|
+
# KEYS[1] = app block key, KEYS[2] = method block key, KEYS[3..] = counters
|
|
19
|
+
# ARGV = limit, window pairs matching KEYS[3..]
|
|
20
|
+
ALLOW_SCRIPT = <<~LUA
|
|
21
|
+
for i = 1, 2 do
|
|
22
|
+
if redis.call('EXISTS', KEYS[i]) == 1 then
|
|
23
|
+
return 0
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
for i = 3, #KEYS do
|
|
27
|
+
local idx = i - 3
|
|
28
|
+
local limit = tonumber(ARGV[idx * 2 + 1])
|
|
29
|
+
local window = tonumber(ARGV[idx * 2 + 2])
|
|
30
|
+
if limit then
|
|
31
|
+
local count = redis.call('INCR', KEYS[i])
|
|
32
|
+
if count == 1 then
|
|
33
|
+
redis.call('EXPIRE', KEYS[i], window)
|
|
34
|
+
end
|
|
35
|
+
if count > limit then
|
|
36
|
+
redis.call('DECR', KEYS[i])
|
|
37
|
+
return 0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
return 1
|
|
42
|
+
LUA
|
|
43
|
+
|
|
44
|
+
def initialize(redis:, sleeper: Kernel.method(:sleep))
|
|
45
|
+
raise DependencyMissing, 'Add the `redis` gem to your Gemfile to use RedisLimiter' unless defined?(::Redis)
|
|
46
|
+
|
|
47
|
+
@redis = redis
|
|
48
|
+
@sleeper = sleeper
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def acquire!(key:, region:, bucket:)
|
|
52
|
+
loop do
|
|
53
|
+
return if allowed?(key, region, bucket)
|
|
54
|
+
|
|
55
|
+
@sleeper.call(1)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def observe!(response, key:, region:, bucket:)
|
|
60
|
+
headers = response.headers
|
|
61
|
+
status = response.status
|
|
62
|
+
prefix = prefix_for(key, region)
|
|
63
|
+
|
|
64
|
+
if status == 429
|
|
65
|
+
block!(prefix, bucket, headers)
|
|
66
|
+
else
|
|
67
|
+
sync_limits!(prefix, bucket, headers)
|
|
68
|
+
sync_counts!(prefix, bucket, headers)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def blocked?(key:, region:, bucket: nil, scope: :app)
|
|
73
|
+
prefix = prefix_for(key, region)
|
|
74
|
+
block_key = scope == :app ? "#{prefix}:block" : "#{prefix}:m:#{bucket}:block"
|
|
75
|
+
@redis.exists?(block_key)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def allowed?(key, region, bucket)
|
|
81
|
+
prefix = prefix_for(key, region)
|
|
82
|
+
method_prefix = "#{prefix}:m:#{bucket}"
|
|
83
|
+
|
|
84
|
+
app_limits = parse_limits(@redis.hget("#{prefix}:limits", 'app'))
|
|
85
|
+
method_limits = parse_limits(@redis.hget("#{prefix}:limits", bucket))
|
|
86
|
+
|
|
87
|
+
keys = ["#{prefix}:block", "#{method_prefix}:block"]
|
|
88
|
+
argv = []
|
|
89
|
+
app_limits.each do |limit, window|
|
|
90
|
+
keys << "#{prefix}:app:#{window}"
|
|
91
|
+
argv << limit.to_s << window.to_s
|
|
92
|
+
end
|
|
93
|
+
method_limits.each do |limit, window|
|
|
94
|
+
keys << "#{method_prefix}:#{window}"
|
|
95
|
+
argv << limit.to_s << window.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@redis.eval(ALLOW_SCRIPT, keys: keys, argv: argv) == 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def block!(prefix, bucket, headers)
|
|
102
|
+
retry_after = HeaderParser.retry_after(headers) || 2
|
|
103
|
+
case HeaderParser.limit_type(headers)
|
|
104
|
+
when :application
|
|
105
|
+
@redis.set("#{prefix}:block", '1', ex: retry_after)
|
|
106
|
+
when :method, :service
|
|
107
|
+
@redis.set("#{prefix}:m:#{bucket}:block", '1', ex: retry_after)
|
|
108
|
+
else
|
|
109
|
+
@redis.set("#{prefix}:m:#{bucket}:block", '1', ex: retry_after)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sync_limits!(prefix, bucket, headers)
|
|
114
|
+
app_limits = HeaderParser.app_limits(headers)
|
|
115
|
+
method_limits = HeaderParser.method_limits(headers)
|
|
116
|
+
return if app_limits.empty? && method_limits.empty?
|
|
117
|
+
|
|
118
|
+
mapping = {}
|
|
119
|
+
mapping['app'] = serialize(app_limits) unless app_limits.empty?
|
|
120
|
+
mapping[bucket] = serialize(method_limits) unless method_limits.empty?
|
|
121
|
+
@redis.hset("#{prefix}:limits", mapping)
|
|
122
|
+
@redis.expire("#{prefix}:limits", LIMITS_TTL)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def sync_counts!(prefix, bucket, headers)
|
|
126
|
+
sync_scope_counts!(prefix, HeaderParser.app_counts(headers), "#{prefix}:app")
|
|
127
|
+
sync_scope_counts!(prefix, HeaderParser.method_counts(headers), "#{prefix}:m:#{bucket}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sync_scope_counts!(_prefix, counts, counter_prefix)
|
|
131
|
+
counts.each do |count, window|
|
|
132
|
+
@redis.set("#{counter_prefix}:#{window}", count.to_s, ex: window)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def serialize(pairs)
|
|
137
|
+
pairs.map { |limit, window| "#{limit}:#{window}" }.join(',')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def parse_limits(value)
|
|
141
|
+
HeaderParser.parse_pairs(value)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def prefix_for(key, region)
|
|
145
|
+
"rito:#{digest(key)}:#{Routing.normalize(region)}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def digest(key)
|
|
149
|
+
Digest::SHA256.hexdigest(key.to_s)[0, 16]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|