rito 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b7b56e2ebcc806ea4e657c2eaf6de65fbf5e2abcb6447a2543c9c7f88fb053f
4
- data.tar.gz: a33ab0b69073f9864d843c857b1dd1ff47664f1ea682e75f4feaa9171f00229c
3
+ metadata.gz: 5ad417d4efb4e93230ccc8e4aba43faaf13b6a6e9f39894e8a0e60ef0666e8ec
4
+ data.tar.gz: f508bc188dbda8b54f8159b9a3382c00cc93145b7188054558a4c9da377ec244
5
5
  SHA512:
6
- metadata.gz: 423b3499635cb2c42180112e470ea0dad001b38c91d35ea28cc33ef58e59ad513abb4751f4c5b39a226ed49041fff64affc5b98e689aec2cc9a60aaad4225c8e
7
- data.tar.gz: 89c6a06c5aabad571775d23faf15dc5c316ec0cf8e8d67b3f294217fd4249c337867e833524a0953002d34b45a93948b65c175184a6c09e108895681c8cfe18b
6
+ metadata.gz: d8dde9a5eac05156304fd1fe8d43e4b9463982eef6e9926b366fb589a17447217b744e19e22dca69151ff268a41f967d1e6e767514cdf08b0a060a46686a4638
7
+ data.tar.gz: cf3993148793c8b5f81b89e27eba30704d63ab74eea357d6ea99ce93eb6a244db483f09ed4b1429f774ec81e969cd5665beffacd32b3d493ddf07257fa32b819
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-09-05)
4
+
5
+ - **Breaking:** LoL `leagues.entries` now requires `queue` before `tier` and
6
+ `division`, matching the live API path.
7
+ - Tournament and tournament-stub requests route to `americas`; `na1` escalates
8
+ automatically, and unsupported clusters are rejected.
9
+ - Account routing normalizes SEA platform values (including `sg2`) to `asia`.
10
+ VALORANT routing values work as client defaults.
11
+ - **Breaking:** Explicit API-key or bearer credentials no longer inherit the other global
12
+ credential. Explicit `nil` clears credentials; mixed credentials are rejected.
13
+ - Transport failures retry before conversion to Rito errors. Non-JSON HTTP
14
+ error bodies preserve the HTTP error class; 403 hints include routing and access.
15
+ Retried 429 POSTs preserve their original request body.
16
+ - Rate-limit buckets isolate API families. Headerless 429 backoff grows
17
+ exponentially, learned windows survive expiry, deadlines do not slide with
18
+ responses, and older counts do not erase in-flight reservations. Application
19
+ and method quota are acquired together.
20
+ - Redis quota checks reserve all windows atomically, preserve counter TTLs,
21
+ accept zero Retry-After, retain longer blocks, and back off across workers.
22
+ - Request instrumentation emits one completion event for successes and errors,
23
+ including bearer requests, with total network attempts and elapsed time.
24
+ - Model fields and raw payloads are recursively frozen. Documentation identifies
25
+ endpoints returning parsed JSON and replaces removed method examples.
26
+ - Added a schema snapshot contract test, live-path regressions, retry and
27
+ instrumentation tests, and Redis concurrency coverage. Explicitly configured
28
+ Redis must be available for tests.
29
+
3
30
  ## 0.3.1 (2026-09-05)
4
31
 
5
32
  Fixed endpoint diffs against the current riotapi-schema (last generated
data/README.md CHANGED
@@ -4,7 +4,7 @@ A modern, production-ready Ruby client for the Riot Games API: League of
4
4
  Legends, TFT, VALORANT, Legends of Runeterra, Riftbound, and the Riot Account API.
5
5
 
6
6
  Full routing-value handling, header-driven adaptive rate limiting, frozen
7
- `Data` models, and a single runtime dependency (Faraday).
7
+ `Data` models, and Faraday transport with faraday-retry.
8
8
 
9
9
  ## Installation
10
10
 
@@ -42,8 +42,14 @@ Rito.rate_limiter # rate limit strategy (default: AdaptiveLimiter)
42
42
 
43
43
  # per-client, for multi-key apps:
44
44
  client = Rito::Client.new(api_key: "RGAPI-...", region: :kr)
45
+ rso_client = Rito::Client.new(bearer_token: access_token, region: :americas)
45
46
  ```
46
47
 
48
+ Explicit credentials select API-key or bearer authentication without inheriting
49
+ the other credential from global config. Use separate clients for API-key and
50
+ RSO requests; configuring both credentials on one client raises `ConfigError`.
51
+ Explicit `nil` clears a credential.
52
+
47
53
  ## Queues
48
54
 
49
55
  `Rito::Queues` is a frozen, built-in reference for League queue ids as
@@ -178,9 +184,10 @@ limiter = Rito::RateLimiting::RedisLimiter.new(redis: Redis.new)
178
184
  client = Rito::Client.new(rate_limiter: limiter)
179
185
  ```
180
186
 
181
- Note: `acquire!` blocks the calling thread while throttled fine in
182
- background jobs; in web requests prefer jobs or `max_retries: 0` with
183
- explicit `Rito::RateLimited` handling.
187
+ `acquire!` blocks the calling thread while throttled, so use background jobs
188
+ for work that must not block a web request. `max_retries: 0` disables retries;
189
+ it does not disable pre-emptive waiting. `NullLimiter` disables limiter-based
190
+ waiting entirely.
184
191
 
185
192
  ### Multi-process (Redis)
186
193
 
@@ -207,16 +214,25 @@ URL, and Riot's error detail.
207
214
 
208
215
  ## Models
209
216
 
210
- Responses are frozen `Data` objects. Documented fields are typed accessors;
211
- unknown fields Riot adds later are preserved in `.raw`:
217
+ Account, summoner, LoL match, mastery, rotation, and league responses have
218
+ `Data` models. Their mapped fields and `.raw` payloads are recursively frozen;
219
+ unknown fields Riot adds later are preserved in `.raw`. Other endpoints return
220
+ parsed JSON hashes, arrays, or scalar values, including timelines, status,
221
+ Clash, challenges, tournaments, and most TFT, VALORANT, and LoR responses:
212
222
 
213
223
  ```ruby
214
- entry = client.leagues.entries_by_summoner_id(summoner_id).first
224
+ entry = client.leagues.entries_by_puuid(puuid).first
215
225
  entry.tier # => "DIAMOND"
216
226
  entry.winrate # => 57.14
217
227
  entry.raw # => full payload hash
218
228
  ```
219
229
 
230
+ League listings require a queue, tier, and division:
231
+
232
+ ```ruby
233
+ client.leagues.entries("RANKED_SOLO_5x5", "DIAMOND", "I", page: 1)
234
+ ```
235
+
220
236
  ## Coverage
221
237
 
222
238
  | Accessor | API |
@@ -244,7 +260,7 @@ against the right set per product. `esports` is a valid platform for
244
260
  VALORANT content/match and a valid regional for tft-match-v1 (with
245
261
  `esportseu`); `apac` is a valid regional for lor-match-v1. The
246
262
  tournament endpoints only exist on the `americas`
247
- platform — pass `region: :na1` or `:americas`.
263
+ host — pass `region: :na1` or `:americas`. Other regional clusters are rejected.
248
264
 
249
265
  ## Testing your own app
250
266
 
@@ -257,8 +273,11 @@ bundle exec rake test
257
273
 
258
274
  ## Instrumentation
259
275
 
260
- If ActiveSupport is present (e.g. in a Rails app), every request emits a
261
- `request.rito` notification:
276
+ If ActiveSupport is present (e.g. in a Rails app), each `Rito::Client` request
277
+ emits one `request.rito` notification after it completes, including failures
278
+ and bearer requests. `attempts` counts network attempts across transport and
279
+ 429 retries; `duration_ms` includes retry and limiter waits. Configuration and
280
+ routing errors that prevent a request do not emit an event:
262
281
 
263
282
  ```ruby
264
283
  ActiveSupport::Notifications.subscribe("request.rito") do |*, payload|
@@ -275,10 +294,24 @@ Without ActiveSupport, instrumentation is a no-op.
275
294
 
276
295
  ```
277
296
  bundle install
297
+ bundle exec rake lint
278
298
  bundle exec rake test
279
299
  ```
280
300
 
281
- Ruby 3.2+ required (`Data.define`).
301
+ Ruby 3.2+ required (`Data.define`). Redis tests use `REDIS_URL` when set and
302
+ fail if that configured Redis is unavailable; otherwise they try localhost
303
+ and skip if it is unavailable.
304
+
305
+ The endpoint contract test uses a checked-in snapshot of riotapi-schema
306
+ (paths, routing hosts, and query parameters). Refresh it with
307
+ `bundle exec rake api_routes`, review the diff, and run the suite.
308
+
309
+ Live read-only checks use `bundle exec ruby scripts/live_matrix.rb` with
310
+ `RIOT_API_KEY_TEST` and `RIOT_API_KEY_PROD` in `.env`. Access varies by key and
311
+ product: a 403 can indicate missing product access or an incorrect path/host,
312
+ as well as an expired key. API keys cannot validate RSO-only endpoints;
313
+ those need an RSO access token. Synthetic-id 404s establish no successful
314
+ payload coverage.
282
315
 
283
316
  ## License
284
317
 
data/lib/rito/client.rb CHANGED
@@ -5,10 +5,13 @@ module Rito
5
5
  attr_reader :api_key, :bearer_token, :region, :limiter, :max_retries,
6
6
  :open_timeout, :timeout, :adapter
7
7
 
8
- def initialize(api_key: nil, bearer_token: nil, region: nil, rate_limiter: nil,
8
+ UNSET = Object.new.freeze
9
+ private_constant :UNSET
10
+
11
+ def initialize(api_key: UNSET, bearer_token: UNSET, region: nil, rate_limiter: nil,
9
12
  max_retries: nil, open_timeout: nil, timeout: nil, adapter: nil)
10
- @api_key = api_key || Rito.api_key
11
- @bearer_token = bearer_token || Rito.bearer_token
13
+ @api_key = api_key.equal?(UNSET) ? (Rito.api_key if bearer_token.equal?(UNSET)) : api_key
14
+ @bearer_token = bearer_token.equal?(UNSET) ? (Rito.bearer_token if api_key.equal?(UNSET)) : bearer_token
12
15
  @region = region || Rito.region
13
16
  @limiter = rate_limiter || Rito.rate_limiter || RateLimiting::AdaptiveLimiter.new
14
17
  @max_retries = max_retries || Rito.max_retries
@@ -92,16 +95,38 @@ module Rito
92
95
  Endpoints::Riftbound.new(self)
93
96
  end
94
97
 
95
- def request(method, path, routing_kind:, routing_value: nil, params: {}, body: nil)
98
+ def request(method, path, routing_kind:, routing_value: nil, regions: nil, params: {}, body: nil)
96
99
  region = Routing.resolve(routing_kind, routing_value || @region)
100
+ raise ArgumentError, "#{region.inspect} is not supported by this endpoint" if regions && !regions.include?(region)
101
+
97
102
  connection = @connection.for_host(Routing.host_for(region))
103
+ context = { attempts: 0 }
104
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ url = connection.build_url(path, params).to_s
98
106
  response = connection.run_request(method, path, body, {}) do |req|
107
+ req.options.context = context
99
108
  req.params.update(params) unless params.empty?
100
109
  end
101
- # 5xx responses reaching here are final: faraday-retry has exhausted
102
- # its retries (or the status was not retryable).
110
+ # Transport errors and final 5xx must be converted after Faraday's retries.
103
111
  raise_server_error!(response) if response.status >= 500
104
112
  response
113
+ rescue Faraday::ConnectionFailed => e
114
+ raise ConnectionError.new('Connection failed', wrapped: e)
115
+ rescue Faraday::TimeoutError, ::Timeout::Error => e
116
+ raise TimeoutError.new('Request timed out', wrapped: e)
117
+ rescue Faraday::SSLError => e
118
+ raise SSLError.new('TLS connection failed', wrapped: e)
119
+ ensure
120
+ if context && context[:attempts].positive?
121
+ error = $ERROR_INFO
122
+ Instrumentation.emit(
123
+ http_method: method, url: url,
124
+ status: response&.status || (error.status if error.respond_to?(:status)),
125
+ attempts: context[:attempts],
126
+ duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2),
127
+ error: error&.class&.name
128
+ )
129
+ end
105
130
  end
106
131
 
107
132
  private
@@ -114,7 +139,11 @@ module Rito
114
139
 
115
140
  def validate!
116
141
  raise ConfigError, 'api_key or bearer_token is required' if @api_key.to_s.empty? && @bearer_token.to_s.empty?
117
- return if Routing.platform?(@region) || Routing.regional?(@region)
142
+ if @api_key && @bearer_token
143
+ raise ConfigError, 'configure either api_key or bearer_token; use separate clients for API and RSO requests'
144
+ end
145
+ return if Routing.platform?(@region) || Routing.regional?(@region) ||
146
+ Routing::VALORANT_PLATFORMS.include?(Routing.normalize(@region))
118
147
 
119
148
  raise ConfigError, "unknown region: #{@region.inspect}"
120
149
  end
@@ -10,19 +10,24 @@ module Rito
10
10
  private
11
11
 
12
12
  def get(path, region = nil, params: {})
13
- @client.request(:get, path, routing_kind: routing_kind, routing_value: region, params: params).body
13
+ @client.request(:get, path, routing_kind: routing_kind, routing_value: region, regions: regions,
14
+ params: params).body
14
15
  end
15
16
 
16
17
  def post(path, region = nil, params: {}, body: nil)
17
- @client.request(:post, path, routing_kind: routing_kind, routing_value: region,
18
+ @client.request(:post, path, routing_kind: routing_kind, routing_value: region, regions: regions,
18
19
  params: params, body: body).body
19
20
  end
20
21
 
21
22
  def put(path, region = nil, params: {}, body: nil)
22
- @client.request(:put, path, routing_kind: routing_kind, routing_value: region,
23
+ @client.request(:put, path, routing_kind: routing_kind, routing_value: region, regions: regions,
23
24
  params: params, body: body).body
24
25
  end
25
26
 
27
+ def regions
28
+ self.class::REGIONS if self.class.const_defined?(:REGIONS)
29
+ end
30
+
26
31
  def routing_kind
27
32
  self.class::ROUTING
28
33
  end
@@ -29,10 +29,10 @@ module Rito
29
29
  .map { |hash| Models::LeagueEntry.from_api(hash) }
30
30
  end
31
31
 
32
- def entries(tier, division, page: nil, region: nil)
32
+ def entries(queue, tier, division, page: nil, region: nil)
33
33
  params = {}
34
34
  params['page'] = page if page
35
- get("/lol/league/v4/entries/#{escape(tier)}/#{escape(division)}", region, params: params)
35
+ get("/lol/league/v4/entries/#{escape(queue)}/#{escape(tier)}/#{escape(division)}", region, params: params)
36
36
  .map { |hash| Models::LeagueEntry.from_api(hash) }
37
37
  end
38
38
  end
@@ -4,7 +4,8 @@ module Rito
4
4
  module Endpoints
5
5
  module Lol
6
6
  class TournamentV5 < Base
7
- ROUTING = :platform
7
+ ROUTING = :regional
8
+ REGIONS = %w[americas].freeze
8
9
  PREFIX = '/lol/tournament/v5'
9
10
 
10
11
  def code(tournament_code, region: nil)
@@ -39,7 +40,8 @@ module Rito
39
40
  end
40
41
 
41
42
  class TournamentStubV5 < Base
42
- ROUTING = :platform
43
+ ROUTING = :regional
44
+ REGIONS = %w[americas].freeze
43
45
  PREFIX = '/lol/tournament-stub/v5'
44
46
 
45
47
  def code(tournament_code, region: nil)
@@ -12,10 +12,10 @@ module Rito
12
12
 
13
13
  def on_complete(env)
14
14
  limiter = @client.limiter
15
- return if limiter.nil? || @client.api_key.nil?
15
+ key = @client.api_key || @client.bearer_token
16
+ return if limiter.nil? || key.nil?
16
17
 
17
- limiter.observe!(Faraday::Response.new(env), key: @client.api_key,
18
- region: region_from(env), bucket: bucket_from(env))
18
+ limiter.observe!(Faraday::Response.new(env), key: key, region: region_from(env), bucket: bucket_from(env))
19
19
  end
20
20
 
21
21
  private
@@ -25,7 +25,7 @@ module Rito
25
25
  end
26
26
 
27
27
  def bucket_from(env)
28
- env.url.path.split('/').first(2).join('/')
28
+ env.url.path.split('/').reject(&:empty?).first(2).join('/')
29
29
  end
30
30
  end
31
31
  end
@@ -15,43 +15,29 @@ module Rito
15
15
 
16
16
  def call(env)
17
17
  limiter = @client.limiter
18
- key = @client.api_key
19
- return @app.call(env) if limiter.nil? || key.nil?
20
-
21
- attempts = 0
22
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ key = @client.api_key || @client.bearer_token
19
+ retries = 0
20
+ request_body = env.body
23
21
  begin
24
- limiter.acquire!(key: key, region: region_from(env), bucket: bucket_from(env))
25
- response = @app.call(env)
26
- emit(env, response.status, started, attempts)
27
- response
28
- rescue Rito::RateLimited => e
29
- emit(env, e.status, started, attempts, error: e.class.name)
30
- attempts += 1
31
- retry if attempts <= @client.max_retries
22
+ limiter&.acquire!(key: key, region: region_from(env), bucket: bucket_from(env))
23
+ env.body = request_body
24
+ env.request.context[:attempts] += 1 if env.request.context
25
+ @app.call(env)
26
+ rescue Rito::RateLimited
27
+ retries += 1
28
+ retry if retries <= @client.max_retries
32
29
  raise
33
30
  end
34
31
  end
35
32
 
36
33
  private
37
34
 
38
- def emit(env, status, started, attempts, error: nil)
39
- Instrumentation.emit(
40
- http_method: env.method,
41
- url: env.url.to_s,
42
- status: status,
43
- attempts: attempts,
44
- duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2),
45
- error: error
46
- )
47
- end
48
-
49
35
  def region_from(env)
50
36
  env.url.host.to_s.split('.').first
51
37
  end
52
38
 
53
39
  def bucket_from(env)
54
- env.url.path.split('/').first(2).join('/')
40
+ env.url.path.split('/').reject(&:empty?).first(2).join('/')
55
41
  end
56
42
  end
57
43
  end
@@ -14,31 +14,13 @@ module Rito
14
14
  415 => Rito::UnsupportedMediaType
15
15
  }.freeze
16
16
 
17
- # 403 from Riot almost always means the key is bad, and dev keys
18
- # expire every 24h — users never guess this from a bare "Forbidden".
19
- KEY_STALENESS_HINT = ' (check the API key: dev keys expire every 24h)'
17
+ FORBIDDEN_HINT = ' (check routing, product access, and the API key: dev keys expire every 24h)'
20
18
 
21
19
  def initialize(app, client:)
22
20
  super(app)
23
21
  @client = client
24
22
  end
25
23
 
26
- def call(env)
27
- @app.call(env).on_complete { |completed| on_complete(completed) }
28
- rescue Faraday::RetriableResponse => e
29
- response = e.response
30
- raise error_class_for(response.status).new(
31
- "#{response.status} (after transport retries)",
32
- response: response
33
- )
34
- rescue Faraday::ConnectionFailed => e
35
- raise Rito::ConnectionError.new("#{e.class}: #{e.message}", wrapped: e)
36
- rescue Faraday::TimeoutError, Timeout::Error => e
37
- raise Rito::TimeoutError.new("#{e.class}: #{e.message}", wrapped: e)
38
- rescue Faraday::SSLError => e
39
- raise Rito::SSLError.new("#{e.class}: #{e.message}", wrapped: e)
40
- end
41
-
42
24
  def on_complete(env)
43
25
  return if env.status.between?(200, 299)
44
26
  # 5xx are retried by faraday-retry (registered above this middleware);
@@ -72,14 +54,21 @@ module Rito
72
54
  end
73
55
  end
74
56
 
57
+ def parse_body(body)
58
+ body = JSON.parse(body) if body.is_a?(String) && !body.empty?
59
+ body.is_a?(Hash) ? body : {}
60
+ rescue JSON::ParserError
61
+ {}
62
+ end
63
+
75
64
  def message(env)
76
- parsed = env.response_body
77
- parsed = JSON.parse(parsed) if parsed.is_a?(String) && !parsed.empty?
78
- detail = parsed.is_a?(Hash) ? (parsed['message'] || parsed.dig('status', 'message')) : nil
65
+ parsed = parse_body(env.response_body)
66
+ status = parsed['status']
67
+ detail = parsed['message'] || (status['message'] if status.is_a?(Hash))
79
68
 
80
69
  base = "#{env.status} #{env.method.to_s.upcase} #{env.url}"
81
70
  text = detail ? "#{base}: #{detail}" : base
82
- text += KEY_STALENESS_HINT if env.status == 403
71
+ text += FORBIDDEN_HINT if env.status == 403
83
72
  text
84
73
  end
85
74
  end
@@ -8,7 +8,7 @@ module Rito
8
8
  puuid: hash['puuid'],
9
9
  game_name: hash['gameName'],
10
10
  tag_line: hash['tagLine'],
11
- raw: hash.freeze
11
+ raw: Models.deep_freeze(hash)
12
12
  )
13
13
  end
14
14
  end
@@ -19,7 +19,7 @@ module Rito
19
19
  puuid: hash['puuid'],
20
20
  game: hash['game'],
21
21
  active_shard: hash['activeShard'],
22
- raw: hash.freeze
22
+ raw: Models.deep_freeze(hash)
23
23
  )
24
24
  end
25
25
  end
@@ -30,7 +30,7 @@ module Rito
30
30
  puuid: hash['puuid'],
31
31
  game: hash['game'],
32
32
  region: hash['region'],
33
- raw: hash.freeze
33
+ raw: Models.deep_freeze(hash)
34
34
  )
35
35
  end
36
36
  end
@@ -17,7 +17,7 @@ module Rito
17
17
  champion_points_since_last_level: hash['championPointsSinceLastLevel'],
18
18
  last_play_time: hash['lastPlayTime'],
19
19
  tokens_earned: hash['tokensEarned'],
20
- raw: hash.freeze
20
+ raw: Models.deep_freeze(hash)
21
21
  )
22
22
  end
23
23
  end
@@ -27,7 +27,7 @@ module Rito
27
27
  new(
28
28
  sr: hash['sr'],
29
29
  newplayer: hash['newplayer'],
30
- raw: hash.freeze
30
+ raw: Models.deep_freeze(hash)
31
31
  )
32
32
  end
33
33
  end
@@ -52,7 +52,7 @@ module Rito
52
52
  fresh_blood: hash['freshBlood'],
53
53
  hot_streak: hash['hotStreak'],
54
54
  inactive: hash['inactive'],
55
- raw: hash.freeze
55
+ raw: Models.deep_freeze(hash)
56
56
  )
57
57
  end
58
58
 
@@ -72,7 +72,7 @@ module Rito
72
72
  name: hash['name'],
73
73
  queue: hash['queue'],
74
74
  entries: hash['entries'].to_a.freeze,
75
- raw: hash.freeze
75
+ raw: Models.deep_freeze(hash)
76
76
  )
77
77
  end
78
78
  end
@@ -11,7 +11,7 @@ module Rito
11
11
  new(
12
12
  metadata: MatchMetadata.from_api(hash['metadata']),
13
13
  info: MatchInfo.from_api(hash['info']),
14
- raw: hash.freeze
14
+ raw: Models.deep_freeze(hash)
15
15
  )
16
16
  end
17
17
  end
@@ -23,7 +23,7 @@ module Rito
23
23
  new(
24
24
  match_id: hash['matchId'],
25
25
  participants: hash['participants'],
26
- raw: hash.freeze
26
+ raw: Models.deep_freeze(hash)
27
27
  )
28
28
  end
29
29
  end
@@ -48,9 +48,9 @@ module Rito
48
48
  game_type: hash['gameType'],
49
49
  game_version: hash['gameVersion'],
50
50
  map_id: hash['mapId'],
51
- participants: hash['participants']&.map { |p| MatchParticipant.from_api(p) },
51
+ participants: hash['participants']&.map { |p| MatchParticipant.from_api(p) }&.freeze,
52
52
  queue_id: hash['queueId'],
53
- raw: hash.freeze
53
+ raw: Models.deep_freeze(hash)
54
54
  )
55
55
  end
56
56
  end
@@ -67,7 +67,7 @@ module Rito
67
67
  summoner_name: hash['summonerName'],
68
68
  team_id: hash['teamId'],
69
69
  win: hash['win'],
70
- raw: hash.freeze
70
+ raw: Models.deep_freeze(hash)
71
71
  )
72
72
  end
73
73
  end
@@ -77,7 +77,7 @@ module Rito
77
77
  new(
78
78
  total: hash['total'],
79
79
  match_file_urls: hash['matchFileURLs'],
80
- raw: hash.freeze
80
+ raw: Models.deep_freeze(hash)
81
81
  )
82
82
  end
83
83
  end
@@ -15,7 +15,7 @@ module Rito
15
15
  profile_icon_id: hash['profileIconId'],
16
16
  revision_date: hash['revisionDate'],
17
17
  summoner_level: hash['summonerLevel'],
18
- raw: hash.freeze
18
+ raw: Models.deep_freeze(hash)
19
19
  )
20
20
  end
21
21
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rito
4
+ module Models
5
+ def self.deep_freeze(value)
6
+ case value
7
+ when Hash
8
+ value.each do |key, item|
9
+ deep_freeze(key)
10
+ deep_freeze(item)
11
+ end
12
+ when Array
13
+ value.each { |item| deep_freeze(item) }
14
+ end
15
+ value.freeze
16
+ end
17
+ end
18
+ end
@@ -15,8 +15,21 @@ module Rito
15
15
  end
16
16
 
17
17
  def acquire!(key:, region:, bucket:)
18
- app_bucket(key, region).wait_until_allowed!
19
- method_bucket(key, region, bucket).wait_until_allowed!
18
+ app = app_bucket(key, region)
19
+ method = method_bucket(key, region, bucket)
20
+ loop do
21
+ delay = @mutex.synchronize do
22
+ [app.delay, method.delay].max.tap do |wait|
23
+ if wait.zero?
24
+ app.consume!
25
+ method.consume!
26
+ end
27
+ end
28
+ end
29
+ return if delay.zero?
30
+
31
+ @sleeper.call(delay + 0.001)
32
+ end
20
33
  end
21
34
 
22
35
  def observe!(response, key:, region:, bucket:)
@@ -26,19 +39,15 @@ module Rito
26
39
  app = app_bucket(key, region)
27
40
  method = method_bucket(key, region, bucket)
28
41
 
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
+ @mutex.synchronize do
43
+ app.apply!(limits: HeaderParser.app_limits(headers), counts: HeaderParser.app_counts(headers),
44
+ limited: status == 429)
45
+ method.apply!(limits: HeaderParser.method_limits(headers), counts: HeaderParser.method_counts(headers),
46
+ limited: status == 429)
47
+ if status == 429
48
+ target = HeaderParser.limit_type(headers) == :application ? app : method
49
+ target.limit_block!(HeaderParser.retry_after(headers))
50
+ end
42
51
  end
43
52
  end
44
53
 
@@ -18,7 +18,7 @@ module Rito
18
18
  @limited_streak = 0
19
19
  end
20
20
 
21
- def apply!(limits:, counts:)
21
+ def apply!(limits:, counts:, limited: false)
22
22
  @mutex.synchronize do
23
23
  now = @clock.call
24
24
  if limits.any?
@@ -28,12 +28,12 @@ module Rito
28
28
  {
29
29
  limit: limit,
30
30
  window: window,
31
- reset_at: now + window,
32
- count: counts_by_window.fetch(window) { previous&.fetch(:count) || 0 }
31
+ reset_at: previous&.fetch(:reset_at) || now + window,
32
+ count: [counts_by_window.fetch(window, 0), previous&.fetch(:count) || 0].max
33
33
  }
34
34
  end
35
35
  end
36
- @limited_streak = 0
36
+ @limited_streak = 0 unless limited
37
37
  end
38
38
  end
39
39
 
@@ -43,33 +43,32 @@ module Rito
43
43
  def limit_block!(retry_after)
44
44
  @mutex.synchronize do
45
45
  now = @clock.call
46
- @limited_streak += 1
47
- delay = retry_after || [@limited_streak * DEFAULT_BACKOFF, MAX_BACKOFF].min
48
- delay += rand * JITTER
46
+ @limited_streak = [@limited_streak + 1, 6].min
47
+ delay = retry_after || [2**(@limited_streak - 1) * DEFAULT_BACKOFF, MAX_BACKOFF].min
48
+ delay += rand * JITTER unless retry_after
49
49
  until_time = now + delay
50
50
  @blocked_until = until_time if @blocked_until.nil? || @blocked_until < until_time
51
51
  end
52
52
  end
53
53
 
54
+ def delay
55
+ @mutex.synchronize { delay_unlocked }
56
+ end
57
+
58
+ def consume!
59
+ @mutex.synchronize { @windows.each { |window| window[:count] += 1 } }
60
+ end
61
+
54
62
  def wait_until_allowed!
55
63
  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
64
+ wait = @mutex.synchronize do
65
+ delay_unlocked.tap do |value|
66
+ @windows.each { |window| window[:count] += 1 } if value.zero?
70
67
  end
71
68
  end
72
- @sleeper.call(delay + 0.001)
69
+ return if wait.zero?
70
+
71
+ @sleeper.call(wait + 0.001)
73
72
  end
74
73
  end
75
74
 
@@ -78,6 +77,22 @@ module Rito
78
77
  @blocked_until && @blocked_until > @clock.call
79
78
  end
80
79
  end
80
+
81
+ private
82
+
83
+ def delay_unlocked
84
+ now = @clock.call
85
+ @windows.each do |window|
86
+ next if window[:reset_at] > now
87
+
88
+ window[:count] = 0
89
+ window[:reset_at] = now + window[:window]
90
+ end
91
+ delays = @windows.filter_map do |window|
92
+ window[:reset_at] - now if window[:count] >= window[:limit]
93
+ end
94
+ [0, (@blocked_until || now) - now, *delays].max
95
+ end
81
96
  end
82
97
  end
83
98
  end
@@ -12,7 +12,9 @@ module Rito
12
12
  limit, window = part.strip.split(':', 2)
13
13
  next if limit.nil? || window.nil?
14
14
 
15
- [Integer(limit), Integer(window)]
15
+ limit = Integer(limit)
16
+ window = Integer(window)
17
+ [limit, window] if limit >= 0 && window.positive?
16
18
  rescue ArgumentError
17
19
  next
18
20
  end
@@ -24,23 +24,47 @@ module Rito
24
24
  end
25
25
  end
26
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
27
+ local limit = tonumber(ARGV[(i - 3) * 2 + 1])
28
+ if tonumber(redis.call('GET', KEYS[i]) or '0') >= limit then
29
+ return 0
30
+ end
31
+ end
32
+ for i = 3, #KEYS do
33
+ redis.call('INCR', KEYS[i])
34
+ if redis.call('PTTL', KEYS[i]) < 0 then
35
+ redis.call('EXPIRE', KEYS[i], ARGV[(i - 3) * 2 + 2])
39
36
  end
40
37
  end
41
38
  return 1
42
39
  LUA
43
40
 
41
+ BLOCK_SCRIPT = <<~LUA
42
+ local streak = math.min(redis.call('INCR', KEYS[2]), 6)
43
+ redis.call('EXPIRE', KEYS[2], 600)
44
+ local delay = tonumber(ARGV[1])
45
+ if delay < 0 then
46
+ delay = math.min(2 ^ (streak - 1), 32) * 1000 + tonumber(ARGV[2])
47
+ end
48
+ if delay > 0 and redis.call('PTTL', KEYS[1]) < delay then
49
+ redis.call('SET', KEYS[1], '1', 'PX', delay)
50
+ end
51
+ LUA
52
+
53
+ SYNC_COUNT_SCRIPT = <<~LUA
54
+ local current = redis.call('GET', KEYS[1])
55
+ local count = tonumber(ARGV[1])
56
+ if not current then
57
+ redis.call('SET', KEYS[1], count, 'EX', ARGV[2])
58
+ elseif count > tonumber(current) then
59
+ local ttl = redis.call('PTTL', KEYS[1])
60
+ if ttl > 0 then
61
+ redis.call('SET', KEYS[1], count, 'PX', ttl)
62
+ else
63
+ redis.call('SET', KEYS[1], count, 'EX', ARGV[2])
64
+ end
65
+ end
66
+ LUA
67
+
44
68
  def initialize(redis:, sleeper: Kernel.method(:sleep))
45
69
  raise DependencyMissing, 'Add the `redis` gem to your Gemfile to use RedisLimiter' unless defined?(::Redis)
46
70
 
@@ -61,11 +85,12 @@ module Rito
61
85
  status = response.status
62
86
  prefix = prefix_for(key, region)
63
87
 
88
+ sync_limits!(prefix, bucket, headers)
89
+ sync_counts!(prefix, bucket, headers)
64
90
  if status == 429
65
91
  block!(prefix, bucket, headers)
66
92
  else
67
- sync_limits!(prefix, bucket, headers)
68
- sync_counts!(prefix, bucket, headers)
93
+ @redis.del("#{prefix}:block:streak", "#{prefix}:m:#{bucket}:block:streak")
69
94
  end
70
95
  end
71
96
 
@@ -99,15 +124,14 @@ module Rito
99
124
  end
100
125
 
101
126
  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
127
+ retry_after = HeaderParser.retry_after(headers)
128
+ block_key = if HeaderParser.limit_type(headers) == :application
129
+ "#{prefix}:block"
130
+ else
131
+ "#{prefix}:m:#{bucket}:block"
132
+ end
133
+ @redis.eval(BLOCK_SCRIPT, keys: [block_key, "#{block_key}:streak"],
134
+ argv: [retry_after ? retry_after * 1000 : -1, rand(500)])
111
135
  end
112
136
 
113
137
  def sync_limits!(prefix, bucket, headers)
@@ -123,13 +147,13 @@ module Rito
123
147
  end
124
148
 
125
149
  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}")
150
+ sync_scope_counts!(HeaderParser.app_counts(headers), "#{prefix}:app")
151
+ sync_scope_counts!(HeaderParser.method_counts(headers), "#{prefix}:m:#{bucket}")
128
152
  end
129
153
 
130
- def sync_scope_counts!(_prefix, counts, counter_prefix)
154
+ def sync_scope_counts!(counts, counter_prefix)
131
155
  counts.each do |count, window|
132
- @redis.set("#{counter_prefix}:#{window}", count.to_s, ex: window)
156
+ @redis.eval(SYNC_COUNT_SCRIPT, keys: ["#{counter_prefix}:#{window}"], argv: [count, window])
133
157
  end
134
158
  end
135
159
 
data/lib/rito/routing.rb CHANGED
@@ -71,11 +71,11 @@ module Rito
71
71
  end
72
72
 
73
73
  def regional_for_account(value)
74
- v = normalize(value)
74
+ v = resolve(:regional, value)
75
+ v = 'asia' if v == 'sea'
75
76
  return v if ACCOUNT_REGIONALS.include?(v)
76
- return 'asia' if v == 'sea'
77
77
 
78
- resolve(:regional, v)
78
+ raise ArgumentError, "#{value.inspect} is not usable as an account routing value"
79
79
  end
80
80
  end
81
81
  end
@@ -15,7 +15,7 @@ module Rito
15
15
  scope: hash['scope'],
16
16
  expires_in: hash['expires_in'],
17
17
  sub_sid: hash['sub_sid'],
18
- raw: hash.freeze
18
+ raw: Models.deep_freeze(hash)
19
19
  )
20
20
  end
21
21
 
@@ -30,7 +30,7 @@ module Rito
30
30
 
31
31
  Userinfo = Data.define(:sub, :cpid, :raw) do
32
32
  def self.from_api(hash)
33
- new(sub: hash['sub'], cpid: hash['cpid'], raw: hash.freeze)
33
+ new(sub: hash['sub'], cpid: hash['cpid'], raw: Models.deep_freeze(hash))
34
34
  end
35
35
  end
36
36
 
data/lib/rito/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rito
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/rito.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  # stdlib
4
4
  require 'json'
5
+ require 'English'
5
6
  require 'uri'
6
7
  require 'erb/util'
7
8
 
@@ -25,6 +26,7 @@ require_relative 'rito/middleware/riot_errors'
25
26
  require_relative 'rito/connection'
26
27
  require_relative 'rito/instrumentation'
27
28
  require_relative 'rito/client'
29
+ require_relative 'rito/models'
28
30
  require_relative 'rito/rso'
29
31
  require_relative 'rito/models/account'
30
32
  require_relative 'rito/models/summoner'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rito
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kodbilenadam
@@ -74,6 +74,7 @@ files:
74
74
  - lib/rito/middleware/rate_limit_observe.rb
75
75
  - lib/rito/middleware/rate_limit_throttle.rb
76
76
  - lib/rito/middleware/riot_errors.rb
77
+ - lib/rito/models.rb
77
78
  - lib/rito/models/account.rb
78
79
  - lib/rito/models/lol.rb
79
80
  - lib/rito/models/match.rb