throttle_machines 0.3.0-x86_64-linux → 0.3.3-x86_64-linux

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: 6930fcc4385b64ab5aaa9fd9ebbaa1b001fd8a55872e84eb70b94fbeb028f9bd
4
- data.tar.gz: a3bdbd07e82ada0f96f3e402c0c4a9e59daeabc592f750dcca1e4e6af8d4bbc8
3
+ metadata.gz: 8094e1210ce074e1bec5f730d483a1fd3968ffcc3b3cb10b2c1705313a6c3fee
4
+ data.tar.gz: 415ff2e1267150c4c466545cfeefe01e9ea30ba6502ddc7e510526bfcbe2679f
5
5
  SHA512:
6
- metadata.gz: 1a408d9c1aedf8b8ae9ec3aaa212c707dc1526907f401b8c1118e0b040eb4fc161cc17b205be3f098864c02364535103ccead629f58a9d3deef1fe32f240f1d3
7
- data.tar.gz: 859411fc180acee7b70865e9f95f20b4de8fd14ba3f1e1c48b35d612ce2455152265c76335aa722b84ee0065aaf62ca95af029b7213a14a2bf591ae5b6f76797
6
+ metadata.gz: b1024d836469d3500560e115232ed349e72b584e734f1ee2d5c39c399c2a760274662383bb7d20f82b104cd2742216f6634e4c590bb7f4335158fa5bf1711220
7
+ data.tar.gz: 1ea545b6198221294e78abeb5240741d26d3d36f61581610fe508a21e4a32413a7596079d9cbd4973028bfa696262918faff497b6c0eaecfdcb90d4414c80056
data/README.md CHANGED
@@ -100,6 +100,32 @@ end
100
100
 
101
101
  ---
102
102
 
103
+ ## Bounded in-memory storage
104
+
105
+ The memory backend tracks at most **65536 entries**, shared across fixed-window
106
+ counters, GCRA states, and token buckets, including when native speedups are loaded.
107
+
108
+ ```ruby
109
+ ThrottleMachines.storage = ThrottleMachines::Storage::Memory.new(max_keys: 10_000)
110
+ ```
111
+
112
+ When full, expired entries are reclaimed before admission. If no slot is
113
+ available, new identities are throttled; active identities are never evicted
114
+ to make room and can continue under their existing limits. Peeks also fail
115
+ closed for untrackable identities. Capacity refusals use a one-second retry
116
+ hint, not a reservation. At saturation, on-demand expiry scans run at most
117
+ once per second, even with `auto_cleanup: false`.
118
+
119
+ `max_keys` must be a non-negative integer; zero denies all identities. For
120
+ direct storage callers, an untrackable fixed-window counter reads/increments
121
+ as `Float::INFINITY` so it cannot pass a finite rate limit.
122
+
123
+ This bounds entry count, not bytes: also bound untrusted identity lengths.
124
+ Redis storage uses TTLs but has no application-level key cap; configure Redis
125
+ memory limits separately. Evicting active Redis keys can reset their limits.
126
+
127
+ ---
128
+
103
129
  ## 🌠 The Ultra-Thin Philosophy
104
130
 
105
131
  Like the best spacecraft, ThrottleMachines follows the principle of **ultra-thin design**:
@@ -21,6 +21,7 @@ begin
21
21
  # Native GCRA implementation using Rust.
22
22
  module NativeGCRA
23
23
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
24
+ reclaim_capacity
24
25
  with_write_lock(key) do
25
26
  now = current_time
26
27
  state = @gcra_states[key] || { tat: 0.0 }
@@ -30,15 +31,20 @@ begin
30
31
  state[:tat], now, emission_interval, delay_tolerance
31
32
  ) => { allowed:, state: new_tat, retry_after: }
32
33
 
33
- @gcra_states[key] = { tat: new_tat, expires_at: now + ttl } if allowed
34
+ if allowed && !store_entry(@gcra_states, key, { tat: new_tat, expires_at: now + ttl })
35
+ return capacity_gcra_refusal(now, delay_tolerance)
36
+ end
34
37
 
35
38
  { allowed: allowed, retry_after: retry_after, tat: new_tat }
36
39
  end
37
40
  end
38
41
 
39
42
  def peek_gcra_limit(key, _emission_interval, delay_tolerance)
43
+ reclaim_capacity
40
44
  with_read_lock(key) do
41
45
  now = current_time
46
+ return capacity_gcra_refusal(now, delay_tolerance) unless capacity_available?(@gcra_states, key)
47
+
42
48
  state = @gcra_states[key] || { tat: 0.0 }
43
49
 
44
50
  # Rust does the math
@@ -54,6 +60,7 @@ begin
54
60
  # Native Token Bucket implementation using Rust.
55
61
  module NativeTokenBucket
56
62
  def check_token_bucket(key, capacity, refill_rate, ttl)
63
+ reclaim_capacity
57
64
  with_write_lock(key) do
58
65
  now = current_time
59
66
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
@@ -64,7 +71,9 @@ begin
64
71
  ) => { allowed:, state: new_tokens, retry_after: }
65
72
 
66
73
  if allowed
67
- @token_buckets[key] = { tokens: new_tokens, last_refill: now, expires_at: now + ttl }
74
+ unless store_entry(@token_buckets, key, { tokens: new_tokens, last_refill: now, expires_at: now + ttl })
75
+ return capacity_token_bucket_refusal
76
+ end
68
77
  end
69
78
 
70
79
  { allowed: allowed, retry_after: retry_after, tokens_remaining: new_tokens.floor }
@@ -72,8 +81,11 @@ begin
72
81
  end
73
82
 
74
83
  def peek_token_bucket(key, capacity, refill_rate)
84
+ reclaim_capacity
75
85
  with_read_lock(key) do
76
86
  now = current_time
87
+ return capacity_token_bucket_refusal unless capacity_available?(@token_buckets, key)
88
+
77
89
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
78
90
 
79
91
  # Rust does the math
@@ -57,8 +57,28 @@ module ThrottleMachines
57
57
  nil
58
58
  end
59
59
 
60
+ # Rounding slack for time comparisons, in units of the clock's precision.
61
+ # Matches the native core.
62
+ SLACK_ULPS = 64
63
+
60
64
  protected
61
65
 
66
+ # Times are running sums and differences of float seconds, so an
67
+ # on-schedule arrival can miss an exact boundary by a few ulps. The slack
68
+ # scales with the clock's magnitude.
69
+ def rounding_slack(time)
70
+ Float::EPSILON * SLACK_ULPS * [time.abs, 1.0].max
71
+ end
72
+
73
+ def gcra_allowed?(tat, now, delay_tolerance)
74
+ tat - now <= delay_tolerance + rounding_slack(tat)
75
+ end
76
+
77
+ # Whether a token is available, allowing for rounding in the refill.
78
+ def token_available?(tokens, now, refill_rate)
79
+ tokens >= 1 - (rounding_slack(now) * refill_rate)
80
+ end
81
+
62
82
  def current_time
63
83
  # Use monotonic time for consistency with BreakerMachines
64
84
  ThrottleMachines.monotonic_time
@@ -5,8 +5,20 @@ require 'concurrent'
5
5
  module ThrottleMachines
6
6
  module Storage
7
7
  class Memory < Base
8
+ DEFAULT_MAX_KEYS = 65_536
9
+ CAPACITY_RETRY_AFTER = 1.0
10
+
8
11
  def initialize(options = {})
9
12
  super
13
+ @max_keys = options.fetch(:max_keys, DEFAULT_MAX_KEYS)
14
+ unless @max_keys.is_a?(Integer) && @max_keys >= 0
15
+ raise ArgumentError, 'max_keys must be a non-negative integer'
16
+ end
17
+
18
+ @stored_keys = 0
19
+ @capacity_lock = Mutex.new
20
+ @capacity_cleanup_lock = Mutex.new
21
+ @next_capacity_cleanup_at = -Float::INFINITY
10
22
  @counters = Concurrent::Hash.new
11
23
  @gcra_states = Concurrent::Hash.new
12
24
  @token_buckets = Concurrent::Hash.new
@@ -31,6 +43,7 @@ module ThrottleMachines
31
43
  # Rate limiting operations
32
44
  def increment_counter(key, window, amount = 1)
33
45
  window_key = "#{key}:#{window}"
46
+ reclaim_capacity
34
47
 
35
48
  with_write_lock(window_key) do
36
49
  now = current_time
@@ -40,22 +53,24 @@ module ThrottleMachines
40
53
  if counter.nil? || counter[:expires_at] <= now
41
54
  # Create or reset counter atomically
42
55
  new_count = amount
43
- @counters[window_key] = { count: new_count, expires_at: now + window }
56
+ stored = store_entry(@counters, window_key, { count: new_count, expires_at: now + window })
44
57
  else
45
58
  # Increment existing counter atomically
46
59
  new_count = counter[:count] + amount
47
- @counters[window_key] = { count: new_count, expires_at: counter[:expires_at] }
60
+ stored = store_entry(@counters, window_key, { count: new_count, expires_at: counter[:expires_at] })
48
61
  end
49
- new_count
62
+ # An untrackable counter must never look like an admitted request.
63
+ stored ? new_count : Float::INFINITY
50
64
  end
51
65
  end
52
66
 
53
67
  def get_counter(key, window)
54
68
  window_key = "#{key}:#{window}"
69
+ reclaim_capacity
55
70
 
56
71
  with_read_lock(window_key) do
57
72
  counter = @counters[window_key]
58
- return 0 unless counter
73
+ return capacity_available?(@counters, window_key) ? 0 : Float::INFINITY unless counter
59
74
  return 0 if counter[:expires_at] <= current_time
60
75
 
61
76
  counter[:count]
@@ -67,7 +82,7 @@ module ThrottleMachines
67
82
 
68
83
  with_read_lock(window_key) do
69
84
  counter = @counters[window_key]
70
- return 0 unless counter
85
+ return capacity_available?(@counters, window_key) ? 0 : CAPACITY_RETRY_AFTER unless counter
71
86
 
72
87
  ttl = counter[:expires_at] - current_time
73
88
  [ttl, 0].max
@@ -76,21 +91,24 @@ module ThrottleMachines
76
91
 
77
92
  def reset_counter(key, window)
78
93
  window_key = "#{key}:#{window}"
79
- with_write_lock(window_key) { @counters.delete(window_key) }
94
+ with_write_lock(window_key) { delete_entry(@counters, window_key) }
80
95
  end
81
96
 
82
97
  # GCRA operations (atomic simulation)
83
98
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
99
+ reclaim_capacity
84
100
  with_write_lock(key) do
85
101
  now = current_time
86
102
  state = @gcra_states[key] || { tat: 0.0 }
87
103
 
88
104
  tat = [state[:tat], now].max
89
- allow = tat - now <= delay_tolerance
105
+ allow = gcra_allowed?(tat, now, delay_tolerance)
90
106
 
91
107
  if allow
92
108
  new_tat = tat + emission_interval
93
- @gcra_states[key] = { tat: new_tat, expires_at: now + ttl }
109
+ unless store_entry(@gcra_states, key, { tat: new_tat, expires_at: now + ttl })
110
+ return capacity_gcra_refusal(now, delay_tolerance)
111
+ end
94
112
  end
95
113
 
96
114
  {
@@ -102,12 +120,15 @@ module ThrottleMachines
102
120
  end
103
121
 
104
122
  def peek_gcra_limit(key, _emission_interval, delay_tolerance)
123
+ reclaim_capacity
105
124
  with_read_lock(key) do
106
125
  now = current_time
126
+ return capacity_gcra_refusal(now, delay_tolerance) unless capacity_available?(@gcra_states, key)
127
+
107
128
  state = @gcra_states[key] || { tat: 0.0 }
108
129
 
109
130
  tat = [state[:tat], now].max
110
- allow = tat - now <= delay_tolerance
131
+ allow = gcra_allowed?(tat, now, delay_tolerance)
111
132
 
112
133
  {
113
134
  allowed: allow,
@@ -119,6 +140,7 @@ module ThrottleMachines
119
140
 
120
141
  # Token bucket operations (atomic simulation)
121
142
  def check_token_bucket(key, capacity, refill_rate, ttl)
143
+ reclaim_capacity
122
144
  with_write_lock(key) do
123
145
  now = current_time
124
146
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
@@ -130,9 +152,11 @@ module ThrottleMachines
130
152
  bucket[:last_refill] = now
131
153
 
132
154
  # Check if we can consume a token
133
- if bucket[:tokens] >= 1
134
- bucket[:tokens] -= 1
135
- @token_buckets[key] = bucket.merge(expires_at: now + ttl)
155
+ if token_available?(bucket[:tokens], now, refill_rate)
156
+ bucket[:tokens] = [bucket[:tokens] - 1, 0.0].max
157
+ unless store_entry(@token_buckets, key, bucket.merge(expires_at: now + ttl))
158
+ return capacity_token_bucket_refusal
159
+ end
136
160
 
137
161
  {
138
162
  allowed: true,
@@ -152,8 +176,11 @@ module ThrottleMachines
152
176
  end
153
177
 
154
178
  def peek_token_bucket(key, capacity, refill_rate)
179
+ reclaim_capacity
155
180
  with_read_lock(key) do
156
181
  now = current_time
182
+ return capacity_token_bucket_refusal unless capacity_available?(@token_buckets, key)
183
+
157
184
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
158
185
 
159
186
  # Calculate tokens without modifying state
@@ -161,11 +188,11 @@ module ThrottleMachines
161
188
  tokens_to_add = elapsed * refill_rate
162
189
  current_tokens = [bucket[:tokens] + tokens_to_add, capacity].min
163
190
 
164
- if current_tokens >= 1
191
+ if token_available?(current_tokens, now, refill_rate)
165
192
  {
166
193
  allowed: true,
167
194
  retry_after: 0,
168
- tokens_remaining: (current_tokens - 1).floor
195
+ tokens_remaining: [current_tokens - 1, 0].max.floor
169
196
  }
170
197
  else
171
198
  retry_after = (1 - current_tokens) / refill_rate
@@ -188,14 +215,17 @@ module ThrottleMachines
188
215
 
189
216
  # Clear matching keys from all stores
190
217
  [@counters, @gcra_states, @token_buckets].each do |store|
191
- store.each_key do |k|
192
- store.delete(k) if k&.match?(regex)
218
+ store.keys.each do |k|
219
+ with_write_lock(k) { delete_entry(store, k) } if k&.match?(regex)
193
220
  end
194
221
  end
195
222
  else
196
- @counters.clear
197
- @gcra_states.clear
198
- @token_buckets.clear
223
+ @capacity_lock.synchronize do
224
+ @counters.clear
225
+ @gcra_states.clear
226
+ @token_buckets.clear
227
+ @stored_keys = 0
228
+ end
199
229
  end
200
230
  end
201
231
 
@@ -212,6 +242,57 @@ module ThrottleMachines
212
242
 
213
243
  private
214
244
 
245
+ # Called with the key's write lock held. The short admission lock makes
246
+ # the cap atomic across stripes and all three stores.
247
+ def store_entry(store, key, value)
248
+ @capacity_lock.synchronize do
249
+ new_key = !store.key?(key)
250
+ return false if new_key && @stored_keys >= @max_keys
251
+
252
+ store[key] = value
253
+ @stored_keys += 1 if new_key
254
+ true
255
+ end
256
+ end
257
+
258
+ def delete_entry(store, key)
259
+ @capacity_lock.synchronize do
260
+ value = store.delete(key)
261
+ @stored_keys -= 1 if value
262
+ value
263
+ end
264
+ end
265
+
266
+ def capacity_available?(store, key)
267
+ @capacity_lock.synchronize { store.key?(key) || @stored_keys < @max_keys }
268
+ end
269
+
270
+ def capacity_gcra_refusal(now, delay_tolerance)
271
+ { allowed: false, retry_after: CAPACITY_RETRY_AFTER, tat: now + delay_tolerance + CAPACITY_RETRY_AFTER }
272
+ end
273
+
274
+ def capacity_token_bucket_refusal
275
+ { allowed: false, retry_after: CAPACITY_RETRY_AFTER, tokens_remaining: 0 }
276
+ end
277
+
278
+ # Run before taking any striped lock. At saturation, attempt expiry
279
+ # cleanup at most once per second, even with auto_cleanup disabled.
280
+ # Rejected identity sprays must not trigger a full scan on every call.
281
+ def reclaim_capacity
282
+ return unless @capacity_lock.synchronize { @stored_keys >= @max_keys }
283
+ return unless @capacity_cleanup_lock.try_lock
284
+
285
+ begin
286
+ now = current_time
287
+ return if now < @next_capacity_cleanup_at
288
+
289
+ @next_capacity_cleanup_at = now + CAPACITY_RETRY_AFTER
290
+ clean_expired_entries
291
+ ensure
292
+ @capacity_cleanup_lock.unlock
293
+ end
294
+ end
295
+
215
296
  def with_read_lock(key, &block)
216
297
  lock_for(key).with_read_lock(&block)
217
298
  end
@@ -242,19 +323,14 @@ module ThrottleMachines
242
323
  def clean_expired_entries
243
324
  now = current_time
244
325
 
245
- # Clean expired counters
246
- @counters.each_pair do |key, data|
247
- with_write_lock(key) { @counters.delete(key) } if data[:expires_at] && data[:expires_at] <= now
248
- end
249
-
250
- # Clean expired GCRA states
251
- @gcra_states.each_pair do |key, data|
252
- with_write_lock(key) { @gcra_states.delete(key) } if data[:expires_at] && data[:expires_at] <= now
253
- end
254
-
255
- # Clean expired token buckets
256
- @token_buckets.each_pair do |key, data|
257
- with_write_lock(key) { @token_buckets.delete(key) } if data[:expires_at] && data[:expires_at] <= now
326
+ [@counters, @gcra_states, @token_buckets].each do |store|
327
+ store.keys.each do |key|
328
+ with_write_lock(key) do
329
+ # Re-read under the lock: a writer may have refreshed the TTL.
330
+ data = store[key]
331
+ delete_entry(store, key) if data && data[:expires_at] && data[:expires_at] <= now
332
+ end
333
+ end
258
334
  end
259
335
 
260
336
  # No breaker state cleanup: breaker state is managed by BreakerMachines
@@ -12,11 +12,15 @@ else
12
12
  end
13
13
 
14
14
  tat = math.max(tat, now)
15
- local allow = (tat - now) <= delay_tolerance
15
+ -- The TAT is a running sum of emission intervals, so an on-schedule arrival
16
+ -- can overshoot the tolerance by a few ulps; the slack scales with the clock.
17
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
18
+ local allow = (tat - now) <= delay_tolerance + slack
16
19
 
17
20
  if allow then
18
21
  local new_tat = tat + emission_interval
19
22
  redis.call('SET', key, new_tat, 'EX', ttl)
20
23
  end
21
24
 
22
- return { allow and 1 or 0, tat }
25
+ -- Redis truncates Lua numbers in replies to integers; send the TAT as text.
26
+ return { allow and 1 or 0, string.format('%.17g', tat) }
@@ -11,6 +11,10 @@ else
11
11
  end
12
12
 
13
13
  tat = math.max(tat, now)
14
- local allow = (tat - now) <= delay_tolerance
14
+ -- The TAT is a running sum of emission intervals, so an on-schedule arrival
15
+ -- can overshoot the tolerance by a few ulps; the slack scales with the clock.
16
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
17
+ local allow = (tat - now) <= delay_tolerance + slack
15
18
 
16
- return { allow and 1 or 0, tat }
19
+ -- Redis truncates Lua numbers in replies to integers; send the TAT as text.
20
+ return { allow and 1 or 0, string.format('%.17g', tat) }
@@ -12,7 +12,13 @@ local elapsed = now - last_refill
12
12
  local tokens_to_add = elapsed * refill_rate
13
13
  tokens = math.min(tokens + tokens_to_add, capacity)
14
14
 
15
- local allow = tokens >= 1
16
- local tokens_after = allow and (tokens - 1) or 0
15
+ -- `elapsed` carries the clock's rounding; a token due exactly now may come
16
+ -- out a hair short of 1. The slack scales with the clock, as in the native core.
17
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
18
+ local allow = tokens >= 1 - slack
19
+ -- Admitted: tokens left after this request. Refused: the fractional balance,
20
+ -- which retry_after is computed from.
21
+ local tokens_after = allow and math.max(tokens - 1, 0) or tokens
17
22
 
18
- return { allow and 1 or 0, tokens_after }
23
+ -- Redis truncates Lua numbers in replies to integers; send the count as text.
24
+ return { allow and 1 or 0, string.format('%.17g', tokens_after) }
@@ -13,11 +13,15 @@ local elapsed = now - last_refill
13
13
  local tokens_to_add = elapsed * refill_rate
14
14
  tokens = math.min(tokens + tokens_to_add, capacity)
15
15
 
16
- local allow = tokens >= 1
16
+ -- `elapsed` carries the clock's rounding; a token due exactly now may come
17
+ -- out a hair short of 1. The slack scales with the clock, as in the native core.
18
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
19
+ local allow = tokens >= 1 - slack
17
20
  if allow then
18
- tokens = tokens - 1
21
+ tokens = math.max(tokens - 1, 0)
19
22
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
20
23
  redis.call('EXPIRE', key, ttl)
21
24
  end
22
25
 
23
- return { allow and 1 or 0, tokens }
26
+ -- Redis truncates Lua numbers in replies to integers; send the count as text.
27
+ return { allow and 1 or 0, string.format('%.17g', tokens) }
@@ -59,13 +59,13 @@ module ThrottleMachines
59
59
 
60
60
  # GCRA operations (atomic via Lua)
61
61
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
62
+ now = current_time
62
63
  result = run_script(GCRA_SCRIPT, GCRA_SHA,
63
64
  keys: [prefixed(key)],
64
- argv: [emission_interval, delay_tolerance, ttl, current_time])
65
+ argv: [emission_interval, delay_tolerance, ttl, now])
65
66
 
66
67
  allowed = result[0] == 1
67
- tat = result[1]
68
- now = current_time
68
+ tat = Float(result[1])
69
69
 
70
70
  {
71
71
  allowed: allowed,
@@ -80,25 +80,18 @@ module ThrottleMachines
80
80
  keys: [prefixed(key)],
81
81
  argv: [capacity, refill_rate, ttl, current_time])
82
82
 
83
- allowed = result[0] == 1
84
- tokens = result[1]
85
-
86
- {
87
- allowed: allowed,
88
- retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
89
- tokens_remaining: tokens.floor
90
- }
83
+ token_bucket_result(result, refill_rate)
91
84
  end
92
85
 
93
86
  # Peek methods for non-consuming checks
94
87
  def peek_gcra_limit(key, emission_interval, delay_tolerance)
88
+ now = current_time
95
89
  result = run_script(PEEK_GCRA_SCRIPT, PEEK_GCRA_SHA,
96
90
  keys: [prefixed(key)],
97
- argv: [emission_interval, delay_tolerance, current_time])
91
+ argv: [emission_interval, delay_tolerance, now])
98
92
 
99
93
  allowed = result[0] == 1
100
- tat = result[1]
101
- now = current_time
94
+ tat = Float(result[1])
102
95
 
103
96
  {
104
97
  allowed: allowed,
@@ -112,14 +105,7 @@ module ThrottleMachines
112
105
  keys: [prefixed(key)],
113
106
  argv: [capacity, refill_rate, current_time])
114
107
 
115
- allowed = result[0] == 1
116
- tokens_remaining = result[1]
117
-
118
- {
119
- allowed: allowed,
120
- retry_after: allowed ? 0 : (1 - tokens_remaining) / refill_rate,
121
- tokens_remaining: tokens_remaining.floor
122
- }
108
+ token_bucket_result(result, refill_rate)
123
109
  end
124
110
 
125
111
  # No circuit breaker operations: breaker state is owned by BreakerMachines
@@ -147,6 +133,19 @@ module ThrottleMachines
147
133
 
148
134
  private
149
135
 
136
+ # `result` is [allowed, tokens]: the balance after an admission, or the
137
+ # fractional balance a refusal waits on. Matches Storage::Memory.
138
+ def token_bucket_result(result, refill_rate)
139
+ allowed = result[0] == 1
140
+ tokens = Float(result[1])
141
+
142
+ {
143
+ allowed: allowed,
144
+ retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
145
+ tokens_remaining: allowed ? tokens.floor : 0
146
+ }
147
+ end
148
+
150
149
  def prefixed(key)
151
150
  "#{@prefix}#{key}"
152
151
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThrottleMachines
4
- VERSION = '0.3.0'
4
+ VERSION = '0.3.3'
5
5
  end
@@ -12,6 +12,7 @@ require 'breaker_machines'
12
12
  # Set up Zeitwerk loader
13
13
  loader = Zeitwerk::Loader.for_gem
14
14
  loader.ignore("#{__dir__}/throttle_machines/engine.rb") unless defined?(Rails::Engine)
15
+ loader.ignore("#{__dir__}/throttle_machines/native_speedup.rb")
15
16
  loader.setup
16
17
 
17
18
  module ThrottleMachines
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: throttle_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.3
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-12 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport