resteze 0.4.0 → 1.0.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 +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +9 -0
- data/README.md +45 -4
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +29 -45
- data/docs/API.md +19 -25
- data/docs/CONFIGURATION.md +281 -0
- data/docs/ERROR_HANDLING.md +94 -27
- data/docs/TESTING.md +21 -0
- data/lib/resteze/action.rb +99 -0
- data/lib/resteze/actions/validation.rb +56 -0
- data/lib/resteze/actions.rb +117 -0
- data/lib/resteze/api_module.rb +11 -5
- data/lib/resteze/api_resource.rb +28 -3
- data/lib/resteze/authenticating.rb +28 -0
- data/lib/resteze/authenticator/api_key.rb +45 -0
- data/lib/resteze/authenticator/basic.rb +40 -0
- data/lib/resteze/authenticator/bearer.rb +37 -0
- data/lib/resteze/authenticator/mtls.rb +73 -0
- data/lib/resteze/authenticator/strategy.rb +133 -0
- data/lib/resteze/authenticator.rb +187 -0
- data/lib/resteze/client.rb +59 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +35 -0
- data/lib/resteze/list.rb +2 -2
- data/lib/resteze/middleware/raise_error.rb +38 -6
- data/lib/resteze/rate_limit.rb +44 -0
- data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
- data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
- data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
- data/lib/resteze/rate_limiter/strategy.rb +58 -0
- data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
- data/lib/resteze/rate_limiter.rb +229 -0
- data/lib/resteze/rate_limiting.rb +30 -0
- data/lib/resteze/response.rb +37 -1
- data/lib/resteze/result.rb +18 -0
- data/lib/resteze/save.rb +25 -3
- data/lib/resteze/synchronized_state.rb +29 -0
- data/lib/resteze/testing/minitest/configuration.rb +3 -1
- data/lib/resteze/testing/minitest/object.rb +30 -0
- data/lib/resteze/testing/rspec/configure.rb +3 -1
- data/lib/resteze/testing/rspec/object.rb +32 -2
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +101 -11
- metadata +23 -2
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
module Resteze
|
|
2
2
|
module Middleware
|
|
3
|
+
# Maps Faraday's errors onto the API module's namespaced hierarchy so a
|
|
4
|
+
# consumer can `rescue MyApi::Error` and catch everything the client
|
|
5
|
+
# raises. Every error is a subclass of MyApi::Error; the Faraday error
|
|
6
|
+
# is kept as `cause`.
|
|
3
7
|
class RaiseError < Faraday::Response::RaiseError
|
|
4
8
|
include Resteze::ApiModule
|
|
5
9
|
|
|
10
|
+
CONNECTION_ERRORS = [Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError].freeze
|
|
11
|
+
|
|
12
|
+
# Faraday error class => namespaced error type. Order matters: the first
|
|
13
|
+
# entry the error is_a? wins, so specific classes come before ClientError.
|
|
14
|
+
STATUS_ERRORS = [
|
|
15
|
+
[Faraday::ResourceNotFound, :ResourceNotFound],
|
|
16
|
+
[Faraday::ConflictError, :ConflictError],
|
|
17
|
+
[Faraday::UnprocessableEntityError, :UnprocessableEntityError],
|
|
18
|
+
[Faraday::UnauthorizedError, :AuthenticationError],
|
|
19
|
+
[Faraday::ForbiddenError, :AuthenticationError],
|
|
20
|
+
[Faraday::TooManyRequestsError, :RateLimitError],
|
|
21
|
+
[Faraday::BadRequestError, :InvalidRequestError],
|
|
22
|
+
[Faraday::ClientError, :ApiError],
|
|
23
|
+
[Faraday::ServerError, :ApiError]
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# Adapter-level failures never reach on_complete, so catch them here.
|
|
27
|
+
def call(env)
|
|
28
|
+
super
|
|
29
|
+
rescue *CONNECTION_ERRORS => e
|
|
30
|
+
raise api_module::ApiConnectionError.from_faraday(e)
|
|
31
|
+
end
|
|
32
|
+
|
|
6
33
|
def on_complete(env)
|
|
7
34
|
super
|
|
8
|
-
rescue Faraday::
|
|
9
|
-
raise
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
35
|
+
rescue Faraday::Error => e
|
|
36
|
+
raise mapped_error(e)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def mapped_error(error)
|
|
42
|
+
_, type = STATUS_ERRORS.find { |faraday_class, _| error.is_a?(faraday_class) }
|
|
43
|
+
return error unless type
|
|
44
|
+
|
|
45
|
+
api_module.const_get(type).from_faraday(error)
|
|
14
46
|
end
|
|
15
47
|
end
|
|
16
48
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
# The value RateLimiter#rate_limit returns: a quota, the calls remaining
|
|
3
|
+
# and the reset. Strategies build it in `describe`; `from_headers` reads
|
|
4
|
+
# the common three-header shape, which FixedWindow uses with
|
|
5
|
+
# `headers: true` and a custom strategy or limiter may use directly.
|
|
6
|
+
class RateLimit
|
|
7
|
+
DEFAULT_HEADERS = {
|
|
8
|
+
limit: "X-RateLimit-Limit",
|
|
9
|
+
remaining: "X-RateLimit-Remaining",
|
|
10
|
+
reset: "X-RateLimit-Reset"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :limit, :remaining, :reset
|
|
14
|
+
|
|
15
|
+
# Returns nil when none of the named headers are present. Values that are
|
|
16
|
+
# not integers are left nil; `reset` is kept as the API sent it (an epoch
|
|
17
|
+
# or a number of seconds, depending on the API).
|
|
18
|
+
def self.from_headers(headers, names = nil)
|
|
19
|
+
names = DEFAULT_HEADERS.merge(names || {})
|
|
20
|
+
values = names.transform_values { |name| header_value(headers, name) }
|
|
21
|
+
return if values.values.all?(&:nil?)
|
|
22
|
+
|
|
23
|
+
new(**values.transform_values { |value| Integer(value.to_s, exception: false) })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.header_value(headers, name)
|
|
27
|
+
(headers || {}).find { |key, _| key.to_s.casecmp?(name) }&.last
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(limit: nil, remaining: nil, reset: nil)
|
|
31
|
+
@limit = limit
|
|
32
|
+
@remaining = remaining
|
|
33
|
+
@reset = reset
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exhausted?
|
|
37
|
+
!remaining.nil? && remaining.zero?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
{ limit:, remaining:, reset: }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class RateLimiter
|
|
3
|
+
# At most `limit` requests per `window` seconds, counted in consecutive
|
|
4
|
+
# windows aligned to the first request. When a window is spent, the next
|
|
5
|
+
# request waits for the next window.
|
|
6
|
+
#
|
|
7
|
+
# With `headers: true` the budget and window follow the API's own
|
|
8
|
+
# X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers
|
|
9
|
+
# as responses come back, so the local view matches the server's even
|
|
10
|
+
# across processes sharing one key. Pass a Hash to name other headers.
|
|
11
|
+
# `reset: :epoch` (the default) reads the reset header as a time;
|
|
12
|
+
# `reset: :seconds` reads it as a countdown. `limit:` may be omitted when
|
|
13
|
+
# the API sends it, or even when it sends only the remaining count.
|
|
14
|
+
#
|
|
15
|
+
# Syncing is conservative: within a window the local remaining count and
|
|
16
|
+
# the server's are both honoured by taking the lower, since requests
|
|
17
|
+
# admitted locally after the server answered are not in its number yet.
|
|
18
|
+
# A reset later than the local window's end starts a fresh window.
|
|
19
|
+
class FixedWindow < Strategy
|
|
20
|
+
attr_reader :limit, :window, :headers, :reset_format
|
|
21
|
+
|
|
22
|
+
def initialize(window:, limit: nil, headers: false, reset: :epoch)
|
|
23
|
+
super()
|
|
24
|
+
@limit = limit && Strategy.integer!(:limit, limit)
|
|
25
|
+
@window = Strategy.number!(:window, window)
|
|
26
|
+
@headers = headers == true ? {} : headers
|
|
27
|
+
@reset_format = reset
|
|
28
|
+
raise ArgumentError, "limit: is required unless headers: syncs it from the API" if @limit.nil? && !@headers
|
|
29
|
+
raise ArgumentError, "reset: must be :epoch or :seconds" unless %i[epoch seconds].include?(reset)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def acquire(state, now)
|
|
33
|
+
roll_window(state, now)
|
|
34
|
+
remaining = state[:remaining]
|
|
35
|
+
return 0 if remaining.nil? # no budget known yet
|
|
36
|
+
return state[:window_started] + window - now if remaining <= 0
|
|
37
|
+
|
|
38
|
+
state[:remaining] = remaining - 1
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def observe(state, response, now)
|
|
43
|
+
return unless headers
|
|
44
|
+
|
|
45
|
+
reported = RateLimit.from_headers(response.http_headers, headers)
|
|
46
|
+
return unless reported
|
|
47
|
+
|
|
48
|
+
roll_window(state, now)
|
|
49
|
+
state[:limit] = reported.limit || state[:limit] || limit
|
|
50
|
+
sync_window(state, reported, now)
|
|
51
|
+
sync_remaining(state, reported)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def describe(state, now)
|
|
55
|
+
roll_window(state, now)
|
|
56
|
+
remaining = state[:remaining] && [state[:remaining], 0].max
|
|
57
|
+
RateLimit.new(limit: state[:limit], remaining:, reset: state[:window_started] + window)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Starts the first window at `now`; afterwards advances by whole
|
|
63
|
+
# windows so they stay aligned to that first request.
|
|
64
|
+
def roll_window(state, now)
|
|
65
|
+
started = state[:window_started]
|
|
66
|
+
return if started && now < started + window
|
|
67
|
+
|
|
68
|
+
state[:window_started] = started ? aligned_start(started, now) : now
|
|
69
|
+
state[:limit] ||= limit
|
|
70
|
+
state[:remaining] = state[:limit]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def aligned_start(started, now)
|
|
74
|
+
started + (((now - started) / window).floor * window)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Aligns the local window to the server's reset. A reset beyond the
|
|
78
|
+
# current window means the server has moved on: start fresh.
|
|
79
|
+
def sync_window(state, reported, now)
|
|
80
|
+
reset_at = trusted_reset(reported.reset, now)
|
|
81
|
+
return unless reset_at
|
|
82
|
+
|
|
83
|
+
fresh = reset_at > state[:window_started] + window + 1e-6
|
|
84
|
+
state[:window_started] = reset_at - window
|
|
85
|
+
state[:remaining] = state[:limit] if fresh
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Resets that are non-finite or further than one window away are not
|
|
89
|
+
# trusted; the latter is clamped to one window from now.
|
|
90
|
+
def trusted_reset(reset, now)
|
|
91
|
+
return unless reset
|
|
92
|
+
|
|
93
|
+
reset_at = reset_format == :seconds ? now + reset : reset.to_f
|
|
94
|
+
return unless reset_at.finite?
|
|
95
|
+
|
|
96
|
+
[reset_at, now + window].min
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sync_remaining(state, reported)
|
|
100
|
+
return unless reported.remaining
|
|
101
|
+
|
|
102
|
+
local = state[:remaining]
|
|
103
|
+
state[:remaining] = local.nil? ? reported.remaining : [local, reported.remaining].min
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class RateLimiter
|
|
3
|
+
# A bucket that holds up to `capacity` requests and drains at `leak_rate`
|
|
4
|
+
# requests per second. Each request adds one unit; a full bucket waits
|
|
5
|
+
# for enough to drain. Unlike a token bucket it smooths bursts into a
|
|
6
|
+
# steady rate rather than allowing them.
|
|
7
|
+
class LeakyBucket < Strategy
|
|
8
|
+
attr_reader :capacity, :leak_rate
|
|
9
|
+
|
|
10
|
+
def initialize(capacity:, leak_rate:)
|
|
11
|
+
super()
|
|
12
|
+
@capacity = Strategy.number!(:capacity, capacity)
|
|
13
|
+
@leak_rate = Strategy.number!(:leak_rate, leak_rate)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def acquire(state, now)
|
|
17
|
+
level = drain(state, now)
|
|
18
|
+
return (level + 1 - capacity) / leak_rate if level + 1 > capacity
|
|
19
|
+
|
|
20
|
+
state[:level] = level + 1
|
|
21
|
+
0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def describe(state, now)
|
|
25
|
+
level = drain(state, now)
|
|
26
|
+
RateLimit.new(limit: capacity.to_i, remaining: (capacity - level).floor,
|
|
27
|
+
reset: level.positive? ? now + (level / leak_rate) : nil)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def drain(state, now)
|
|
33
|
+
last = state[:leaked_at] || now
|
|
34
|
+
level = [0.0, state.fetch(:level, 0.0) - ((now - last) * leak_rate)].max
|
|
35
|
+
state[:leaked_at] = now
|
|
36
|
+
state[:level] = level
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class RateLimiter
|
|
3
|
+
# At most `limit` requests in any trailing `window` seconds. Exact: it
|
|
4
|
+
# keeps the time of each admitted request until it ages out, so memory
|
|
5
|
+
# is bounded by `limit`.
|
|
6
|
+
class SlidingWindow < Strategy
|
|
7
|
+
attr_reader :limit, :window
|
|
8
|
+
|
|
9
|
+
def initialize(limit:, window:)
|
|
10
|
+
super()
|
|
11
|
+
@limit = Strategy.integer!(:limit, limit)
|
|
12
|
+
@window = Strategy.number!(:window, window)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def acquire(state, now)
|
|
16
|
+
times = expire(state, now)
|
|
17
|
+
return times.first + window - now if times.size >= limit
|
|
18
|
+
|
|
19
|
+
times << now
|
|
20
|
+
0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def describe(state, now)
|
|
24
|
+
times = expire(state, now)
|
|
25
|
+
RateLimit.new(limit:, remaining: limit - times.size, reset: times.first && (times.first + window))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def expire(state, now)
|
|
31
|
+
times = (state[:times] ||= [])
|
|
32
|
+
times.shift while times.first && times.first <= now - window
|
|
33
|
+
times
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class RateLimiter
|
|
3
|
+
# A rate-limiting algorithm. Strategies are stateless objects: all
|
|
4
|
+
# bookkeeping goes in the `state` hash the limiter hands them, already
|
|
5
|
+
# under the limiter's lock. Times are Floats of seconds since the epoch.
|
|
6
|
+
#
|
|
7
|
+
# To add one, subclass this, implement `acquire`, and pass the class to
|
|
8
|
+
# `strategy` instead of a name.
|
|
9
|
+
class Strategy
|
|
10
|
+
BUILT_IN = {
|
|
11
|
+
fixed_window: "Resteze::RateLimiter::FixedWindow",
|
|
12
|
+
sliding_window: "Resteze::RateLimiter::SlidingWindow",
|
|
13
|
+
token_bucket: "Resteze::RateLimiter::TokenBucket",
|
|
14
|
+
leaky_bucket: "Resteze::RateLimiter::LeakyBucket"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.lookup(name)
|
|
18
|
+
class_name = BUILT_IN[name.to_sym] or
|
|
19
|
+
raise ArgumentError, "unknown rate-limit strategy #{name.inspect}; known: #{BUILT_IN.keys.join(', ')}"
|
|
20
|
+
|
|
21
|
+
class_name.constantize
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Coerces a count option to a positive Integer, so a String from the
|
|
25
|
+
# environment is caught at configuration time rather than on the first
|
|
26
|
+
# comparison.
|
|
27
|
+
def self.integer!(name, value)
|
|
28
|
+
positive!(name, Integer(value, exception: false), value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Coerces a rate, capacity or duration option to a positive Float.
|
|
32
|
+
def self.number!(name, value)
|
|
33
|
+
positive!(name, Float(value, exception: false), value)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.positive!(name, coerced, original = coerced)
|
|
37
|
+
return coerced if coerced&.positive? && (!coerced.is_a?(Float) || coerced.finite?)
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "#{name} must be a positive number, got #{original.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Admit one request now and return 0, or return the seconds until one
|
|
43
|
+
# could be admitted, having recorded nothing.
|
|
44
|
+
def acquire(_state, _now)
|
|
45
|
+
0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Learn from a Response or Error the API sent back. Most algorithms
|
|
49
|
+
# have nothing to learn; FixedWindow syncs with the API's headers.
|
|
50
|
+
def observe(state, response, now); end
|
|
51
|
+
|
|
52
|
+
# The strategy's current view as a Resteze::RateLimit, for `rate_limit`.
|
|
53
|
+
def describe(_state, _now)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
class RateLimiter
|
|
3
|
+
# A bucket of `capacity` tokens refilled at `refill_rate` tokens per
|
|
4
|
+
# second. Each request takes one token; an empty bucket waits for the
|
|
5
|
+
# next refill. Allows bursts up to `capacity` after a quiet period.
|
|
6
|
+
class TokenBucket < Strategy
|
|
7
|
+
attr_reader :capacity, :refill_rate
|
|
8
|
+
|
|
9
|
+
def initialize(capacity:, refill_rate:)
|
|
10
|
+
super()
|
|
11
|
+
@capacity = Strategy.number!(:capacity, capacity)
|
|
12
|
+
@refill_rate = Strategy.number!(:refill_rate, refill_rate)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def acquire(state, now)
|
|
16
|
+
tokens = refill(state, now)
|
|
17
|
+
return (1 - tokens) / refill_rate if tokens < 1
|
|
18
|
+
|
|
19
|
+
state[:tokens] = tokens - 1
|
|
20
|
+
0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def describe(state, now)
|
|
24
|
+
tokens = refill(state, now)
|
|
25
|
+
RateLimit.new(limit: capacity.to_i, remaining: tokens.floor,
|
|
26
|
+
reset: tokens < 1 ? now + ((1 - tokens) / refill_rate) : nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def refill(state, now)
|
|
32
|
+
last = state[:refilled_at] || now
|
|
33
|
+
tokens = [capacity, state.fetch(:tokens, capacity) + ((now - last) * refill_rate)].min
|
|
34
|
+
state[:refilled_at] = now
|
|
35
|
+
state[:tokens] = tokens
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
require "resteze/synchronized_state"
|
|
2
|
+
|
|
3
|
+
module Resteze
|
|
4
|
+
# The interface through which a client describes, and responds to, its
|
|
5
|
+
# API's rate limiting. Resteze assumes nothing about the mechanics: every
|
|
6
|
+
# method here is a no-op until a strategy is chosen or a method overridden,
|
|
7
|
+
# and `include Resteze` gives each API module its own MyApi::RateLimiter
|
|
8
|
+
# subclass to fill in. One instance per module, shared by every thread, is
|
|
9
|
+
# reached as MyApi.rate_limiter.
|
|
10
|
+
#
|
|
11
|
+
# The client calls, for every request:
|
|
12
|
+
#
|
|
13
|
+
# before_request(method, path, params:, headers:)
|
|
14
|
+
# Before sending. With a strategy, waits until the request fits the
|
|
15
|
+
# budget. Override to throttle differently, or raise to refuse.
|
|
16
|
+
# track(response_or_error)
|
|
17
|
+
# After every Response and every MyApi::Error. With a strategy, lets
|
|
18
|
+
# it learn from what the API reported.
|
|
19
|
+
# retry?(error, attempt)
|
|
20
|
+
# After a MyApi::RateLimitError. Return true to retry; `attempt`
|
|
21
|
+
# counts failed attempts so far, starting at 1. Default false.
|
|
22
|
+
# retry_after(error)
|
|
23
|
+
# Seconds to wait before that retry. Default: the error's Retry-After.
|
|
24
|
+
#
|
|
25
|
+
# and whenever something reads `rate_limit` on a Response or an Error:
|
|
26
|
+
#
|
|
27
|
+
# rate_limit(response_or_error)
|
|
28
|
+
# Whatever describes the limit. With a strategy, a Resteze::RateLimit
|
|
29
|
+
# of the strategy's current view; otherwise nil.
|
|
30
|
+
#
|
|
31
|
+
# Both a Response and a MyApi::Error answer http_status, http_headers and
|
|
32
|
+
# http_body; a Response has the parsed body in `data`, an Error in `json_body`.
|
|
33
|
+
#
|
|
34
|
+
# The four common algorithms are built in and chosen with `strategy`:
|
|
35
|
+
#
|
|
36
|
+
# module MyApi
|
|
37
|
+
# class RateLimiter
|
|
38
|
+
# strategy :fixed_window, limit: 250_000, window: 3600, headers: true
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# strategy :fixed_window, limit: 100, window: 60 # N per clock window
|
|
43
|
+
# strategy :sliding_window, limit: 100, window: 60 # N per trailing window
|
|
44
|
+
# strategy :token_bucket, capacity: 100, refill_rate: 10 # bursts, refilled per second
|
|
45
|
+
# strategy :leaky_bucket, capacity: 100, leak_rate: 10 # smoothed, drained per second
|
|
46
|
+
#
|
|
47
|
+
# `headers: true` on a fixed window syncs the local budget and window with
|
|
48
|
+
# the API's X-RateLimit-* headers (or `headers: { remaining: "...", ... }`
|
|
49
|
+
# for other names). Two further options apply to any strategy:
|
|
50
|
+
#
|
|
51
|
+
# on_wait: :sleep (default) waits in the calling thread; :raise raises
|
|
52
|
+
# MyApi::RateLimitError with the wait in retry_after instead,
|
|
53
|
+
# for callers such as background jobs that reschedule.
|
|
54
|
+
# max_wait: the longest wait honoured, in seconds (default 3600). Any
|
|
55
|
+
# longer, non-finite or absurd wait, such as one computed from
|
|
56
|
+
# a bad header, is clamped to it.
|
|
57
|
+
#
|
|
58
|
+
# Every wait, whether from a strategy or before a retry, goes through
|
|
59
|
+
# `wait`, so both options and the injectable sleeper apply everywhere.
|
|
60
|
+
#
|
|
61
|
+
# State lives in a mutex-guarded hash so a subclass need not think about
|
|
62
|
+
# threads: read a deep-copied snapshot with `state`, change it with
|
|
63
|
+
# `update_state`, or work on the live hash inside `synchronize` for
|
|
64
|
+
# read-modify-write sequences. Avoid instance variables. Waiting always
|
|
65
|
+
# happens outside the lock, so one thread's wait never blocks another
|
|
66
|
+
# thread's bookkeeping.
|
|
67
|
+
class RateLimiter
|
|
68
|
+
include Resteze::ApiModule
|
|
69
|
+
include Resteze::SynchronizedState
|
|
70
|
+
|
|
71
|
+
ON_WAIT = %i[sleep raise].freeze
|
|
72
|
+
DEFAULT_MAX_WAIT = 3600
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
# Chooses the built-in strategy (by name) or a custom Strategy class
|
|
76
|
+
# that instances of this limiter use, with `on_wait:` and `max_wait:`
|
|
77
|
+
# for the limiter itself and any other options for the strategy.
|
|
78
|
+
# Without arguments, returns the [class, options] pair in effect for
|
|
79
|
+
# this class, inherited from a superclass when it declares none.
|
|
80
|
+
def strategy(name = nil, **options)
|
|
81
|
+
return strategy_spec if name.nil? && options.empty?
|
|
82
|
+
raise ArgumentError, "strategy needs a name or class before its options" if name.nil?
|
|
83
|
+
|
|
84
|
+
configure(**options.extract!(:on_wait, :max_wait))
|
|
85
|
+
@strategy = [name.is_a?(Class) ? name : Strategy.lookup(name), options]
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def configure(on_wait: nil, max_wait: nil)
|
|
90
|
+
@on_wait = validate_on_wait(on_wait) if on_wait
|
|
91
|
+
@max_wait = validate_max_wait(max_wait) if max_wait
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def strategy_spec
|
|
95
|
+
@strategy || (superclass.respond_to?(:strategy_spec) ? superclass.strategy_spec : nil)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def strategy_instance
|
|
99
|
+
spec = strategy_spec
|
|
100
|
+
spec && spec[0].new(**spec[1])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def on_wait
|
|
104
|
+
@on_wait || (superclass.respond_to?(:on_wait) ? superclass.on_wait : :sleep)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def max_wait
|
|
108
|
+
@max_wait || (superclass.respond_to?(:max_wait) ? superclass.max_wait : DEFAULT_MAX_WAIT)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_on_wait(value)
|
|
112
|
+
return value if ON_WAIT.include?(value)
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "on_wait: must be one of #{ON_WAIT.map(&:inspect).join(', ')}, got #{value.inspect}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_max_wait(value)
|
|
118
|
+
Strategy.number!(:max_wait, value)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
attr_reader :strategy, :on_wait, :max_wait
|
|
123
|
+
attr_writer :api_module
|
|
124
|
+
|
|
125
|
+
def initialize(strategy: self.class.strategy_instance, on_wait: self.class.on_wait,
|
|
126
|
+
max_wait: self.class.max_wait, clock: nil, sleeper: nil)
|
|
127
|
+
initialize_state
|
|
128
|
+
@strategy = strategy
|
|
129
|
+
@on_wait = self.class.validate_on_wait(on_wait)
|
|
130
|
+
@max_wait = self.class.validate_max_wait(max_wait)
|
|
131
|
+
@clock = clock || -> { Time.now.to_f }
|
|
132
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The module this limiter serves. Set by MyApi.rate_limiter= so that a
|
|
136
|
+
# replacement limiter of any class still raises MyApi's errors.
|
|
137
|
+
def api_module
|
|
138
|
+
@api_module || self.class.api_module
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Receives (method, path, params:, headers:); the default needs none.
|
|
142
|
+
def before_request(*, **)
|
|
143
|
+
throttle if strategy
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def track(response)
|
|
147
|
+
return unless strategy
|
|
148
|
+
|
|
149
|
+
synchronize { |state| strategy.observe(state, response, now) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def rate_limit(_response)
|
|
153
|
+
return unless strategy
|
|
154
|
+
|
|
155
|
+
synchronize { |state| strategy.describe(state, now) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def retry?(_error, _attempt)
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def retry_after(error)
|
|
163
|
+
error.retry_after
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Waits, outside the lock, until the strategy admits one request, and
|
|
167
|
+
# records it. Returns the total seconds waited.
|
|
168
|
+
def throttle
|
|
169
|
+
waited = 0.0
|
|
170
|
+
loop do
|
|
171
|
+
seconds = synchronize { |state| strategy.acquire(state, now) }
|
|
172
|
+
break if seconds <= 0
|
|
173
|
+
|
|
174
|
+
waited += wait(seconds, reason: :throttle)
|
|
175
|
+
end
|
|
176
|
+
waited
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Honours on_wait and max_wait for a wait of `seconds`: sleeps through
|
|
180
|
+
# the injectable sleeper and returns the seconds slept, or raises
|
|
181
|
+
# `error` (default: a fresh MyApi::RateLimitError carrying the wait in
|
|
182
|
+
# retry_after) under on_wait: :raise. Either way it fires a
|
|
183
|
+
# `rate_limit.wait.<key>` notification whose duration is the time spent
|
|
184
|
+
# waiting and whose payload says why (`reason`: :throttle or :retry,
|
|
185
|
+
# `attempt` for a retry, `seconds`, `on_wait`); a raised wait carries the
|
|
186
|
+
# exception keys ActiveSupport adds.
|
|
187
|
+
def wait(seconds, error: nil, reason: :throttle, attempt: nil)
|
|
188
|
+
seconds = clamp(seconds)
|
|
189
|
+
payload = { api_module: api_module.name, seconds:, reason:, attempt:, on_wait: }.compact
|
|
190
|
+
|
|
191
|
+
ActiveSupport::Notifications.instrument("rate_limit.wait.#{instrumentation_key}", payload) do
|
|
192
|
+
raise(error || wait_error(seconds)) if on_wait == :raise
|
|
193
|
+
|
|
194
|
+
@sleeper.call(seconds) if seconds.positive?
|
|
195
|
+
seconds
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def now
|
|
202
|
+
@clock.call
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def clamp(seconds)
|
|
206
|
+
seconds = seconds.to_f
|
|
207
|
+
return max_wait.to_f unless seconds.finite?
|
|
208
|
+
|
|
209
|
+
seconds.clamp(0.0, max_wait.to_f)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The module's INSTRUMENTATION_KEY, or its underscored name for a bare
|
|
213
|
+
# Resteze::RateLimiter that serves no module.
|
|
214
|
+
def instrumentation_key
|
|
215
|
+
mod = api_module
|
|
216
|
+
mod.const_defined?(:INSTRUMENTATION_KEY, false) ? mod::INSTRUMENTATION_KEY : mod.name.underscore.tr("/", "_")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def wait_error(seconds)
|
|
220
|
+
api_module::RateLimitError.new("rate limit reached; retry in #{seconds.ceil} seconds", retry_after: seconds.ceil)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
require "resteze/rate_limiter/strategy"
|
|
226
|
+
require "resteze/rate_limiter/fixed_window"
|
|
227
|
+
require "resteze/rate_limiter/sliding_window"
|
|
228
|
+
require "resteze/rate_limiter/token_bucket"
|
|
229
|
+
require "resteze/rate_limiter/leaky_bucket"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Resteze
|
|
2
|
+
# Drives the API module's RateLimiter through the request lifecycle (see
|
|
3
|
+
# Resteze::RateLimiter). Prepended outermost on Client so that each attempt
|
|
4
|
+
# of a retried request is instrumented and logged on its own. Passes every
|
|
5
|
+
# keyword through untouched, so a client that accepts more than
|
|
6
|
+
# `headers:` and `params:` still works.
|
|
7
|
+
module RateLimiting
|
|
8
|
+
def execute_request(method, path, **)
|
|
9
|
+
limiter = api_module.rate_limiter
|
|
10
|
+
attempt = 0
|
|
11
|
+
sent = false
|
|
12
|
+
begin
|
|
13
|
+
limiter.before_request(method, path, **)
|
|
14
|
+
sent = true
|
|
15
|
+
super.tap { |response| limiter.track(response) }
|
|
16
|
+
rescue api_module::Error => e
|
|
17
|
+
# A refusal from before_request never went out, so there is nothing
|
|
18
|
+
# to track and nothing to retry.
|
|
19
|
+
raise unless sent
|
|
20
|
+
|
|
21
|
+
limiter.track(e)
|
|
22
|
+
raise unless e.is_a?(api_module::RateLimitError) && limiter.retry?(e, attempt += 1)
|
|
23
|
+
|
|
24
|
+
sent = false
|
|
25
|
+
limiter.wait(limiter.retry_after(e), error: e, reason: :retry, attempt:) # :raise raises the 429 itself
|
|
26
|
+
retry
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|