resteze 0.3.1 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +38 -0
  4. data/CLAUDE.md +83 -0
  5. data/README.md +75 -3
  6. data/docs/ACTIONS.md +329 -0
  7. data/docs/ADVANCED_USAGE.md +744 -0
  8. data/docs/API.md +404 -0
  9. data/docs/CONFIGURATION.md +962 -0
  10. data/docs/ERROR_HANDLING.md +676 -0
  11. data/docs/TESTING.md +789 -0
  12. data/lib/resteze/action.rb +99 -0
  13. data/lib/resteze/actions/validation.rb +56 -0
  14. data/lib/resteze/actions.rb +117 -0
  15. data/lib/resteze/api_module.rb +11 -5
  16. data/lib/resteze/api_resource.rb +28 -3
  17. data/lib/resteze/authenticating.rb +28 -0
  18. data/lib/resteze/authenticator/api_key.rb +45 -0
  19. data/lib/resteze/authenticator/basic.rb +40 -0
  20. data/lib/resteze/authenticator/bearer.rb +37 -0
  21. data/lib/resteze/authenticator/mtls.rb +73 -0
  22. data/lib/resteze/authenticator/strategy.rb +133 -0
  23. data/lib/resteze/authenticator.rb +187 -0
  24. data/lib/resteze/client.rb +60 -11
  25. data/lib/resteze/errors.rb +125 -4
  26. data/lib/resteze/instrumentation.rb +49 -0
  27. data/lib/resteze/list.rb +2 -2
  28. data/lib/resteze/middleware/raise_error.rb +38 -6
  29. data/lib/resteze/rate_limit.rb +44 -0
  30. data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
  31. data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
  32. data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
  33. data/lib/resteze/rate_limiter/strategy.rb +58 -0
  34. data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
  35. data/lib/resteze/rate_limiter.rb +229 -0
  36. data/lib/resteze/rate_limiting.rb +30 -0
  37. data/lib/resteze/response.rb +37 -1
  38. data/lib/resteze/result.rb +18 -0
  39. data/lib/resteze/save.rb +25 -3
  40. data/lib/resteze/synchronized_state.rb +29 -0
  41. data/lib/resteze/testing/minitest/configuration.rb +3 -1
  42. data/lib/resteze/testing/minitest/object.rb +30 -0
  43. data/lib/resteze/testing/rspec/configure.rb +3 -1
  44. data/lib/resteze/testing/rspec/object.rb +32 -2
  45. data/lib/resteze/version.rb +1 -1
  46. data/lib/resteze.rb +102 -10
  47. metadata +30 -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::ConflictError => e
9
- raise api_module::ConflictError, e
10
- rescue Faraday::UnprocessableEntityError => e
11
- raise api_module::UnprocessableEntityError, e
12
- rescue Faraday::ResourceNotFound => e
13
- raise api_module::ResourceNotFound, e
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