hanikamu-rate-limit 0.1.0 → 0.2.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: af12b9492738472c4e8bd8431a20b053bde92ec6af556dcd6db9af75c9529e70
4
- data.tar.gz: '0187f0d7f9d9edde19f5b095368b19feb90c73a51d25301e356161930392ccaf'
3
+ metadata.gz: 9eb792d3f4dc535c9a07eee7d06e701265ac45494f2b809afb8701404c64f347
4
+ data.tar.gz: 505b42072d9a2ece067dd670c97abf03a4dc131fae7d3a73d105f2df8352007c
5
5
  SHA512:
6
- metadata.gz: b77fb01c48f98d938bdbd4954672be5a3f2fbbed522d4c662349d4b3e6c98327893d7849bc8b8c4936cf33e13af7e5487805f4da6b7451ff4274471d0e42363b
7
- data.tar.gz: 2bbe7515db8c173aab88c2ee198d0b9a4fb1015091d74706bb0045a31c37166cb1073a29806937db4057e279183c51fe5fd0e7e804a258d4463e29d2fca0ba04
6
+ metadata.gz: 8dce476cdc58a439d23b3015581b17869fe964c5a49344a63c38cb34f817c29140adc6035c461776654e2a0c10dd95625a490afaf1e755d6a1f0e61e8ce607fa
7
+ data.tar.gz: c929411ce3f465454813d5a496fa9fdc3bdf4339ab0ac789b431b53e95515484531bc092507f8242b643a410c723215b3af8ee7b7c6fd442aa87e39ca5af9b3c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-02-11
4
+
5
+ ### Breaking Changes
6
+
7
+ - Removed `limit_with`. Use `limit_method` with the `registry:` option instead.
8
+ - Before: `limit_with :execute, registry: :external_api`
9
+ - After: `limit_method :execute, registry: :external_api`
10
+ - `limit_method` now raises `ArgumentError` when called without `registry:` or `rate:`.
11
+ - `limit_method` raises `ArgumentError` when `registry:` is combined with any other option (`rate:`, `interval:`, `check_interval:`, or `max_wait_time:`).
12
+ - Removed `key_prefix` from `limit_method`. Registry-based limits derive their key automatically.
13
+ - Removed `key_prefix` from `register_limit`. Registry keys are now always derived internally from the registry name.
14
+
15
+ ### Added
16
+
17
+ - `register_temporary_limit(name, remaining:, reset:)` — dynamically override a registered limit with a fixed-window counter and TTL, based on API response headers.
18
+ - Override-exhausted requests raise `RateLimitError` immediately when the remaining TTL exceeds `max_wait_time`, instead of polling.
19
+ - Input validation for `register_temporary_limit` — returns `false` for nil, negative, zero, or non-numeric values.
20
+
3
21
  ## 0.1.0 - 2026-02-04
4
22
 
5
23
  - Initial release of Hanikamu::RateLimit.
data/README.md CHANGED
@@ -32,7 +32,7 @@ Requires Ruby 4.0 or later.
32
32
 
33
33
  ```ruby
34
34
  # Gemfile
35
- gem "hanikamu-rate-limit", "~> 0.1.0"
35
+ gem "hanikamu-rate-limit", "~> 0.2.0"
36
36
  ```
37
37
 
38
38
  ```bash
@@ -84,7 +84,8 @@ Registered limit options:
84
84
 
85
85
  - `rate` and `interval` (required).
86
86
  - `check_interval`, `max_wait_time` (optional).
87
- - `key_prefix` (optional) to force a shared Redis key; defaults to a registry-based prefix.
87
+
88
+ `key_prefix` is no longer configurable for registered limits; registry keys are derived from the registry name.
88
89
 
89
90
  ## Usage
90
91
 
@@ -94,13 +95,21 @@ Optional per-method overrides:
94
95
  limit_method :execute, rate: 5, interval: 1.0, check_interval: 0.1, max_wait_time: 3.0
95
96
  ```
96
97
 
98
+ Optional block called each time the limiter sleeps:
99
+
100
+ ```ruby
101
+ limit_method :execute, rate: 5, interval: 1.0 do |sleep_time|
102
+ Rails.logger.info("Rate limited, sleeping #{sleep_time}s")
103
+ end
104
+ ```
105
+
97
106
  Use a registered limit shared across classes:
98
107
 
99
108
  ```ruby
100
109
  class ExternalApiClient
101
110
  extend Hanikamu::RateLimit::Mixin
102
111
 
103
- limit_with :execute, registry: :external_api
112
+ limit_method :execute, registry: :external_api
104
113
 
105
114
  def execute
106
115
  # work
@@ -108,11 +117,13 @@ class ExternalApiClient
108
117
  end
109
118
  ```
110
119
 
120
+ You must provide either `registry:` or `rate:` — combining them raises `ArgumentError`.
121
+ When `registry:` is used, it must be the only limit-related option (no `rate:`, `interval:`, `check_interval:`, or `max_wait_time:` overrides).
122
+
111
123
  Registry precedence (highest to lowest):
112
124
 
113
- 1. Per-method overrides passed to `limit_with`.
114
- 2. Registered limit options.
115
- 3. Global defaults from `Hanikamu::RateLimit.configure`.
125
+ 1. Registered limit options.
126
+ 2. Global defaults from `Hanikamu::RateLimit.configure`.
116
127
 
117
128
  Reset method is generated automatically:
118
129
 
@@ -120,6 +131,88 @@ Reset method is generated automatically:
120
131
  MyService.reset_execute_limit!
121
132
  ```
122
133
 
134
+ ### Dynamic overrides
135
+
136
+ Dynamic overrides only apply to **registry-based limits** (methods using `limit_method` with `registry:`). Methods limited with inline `rate:` / `interval:` options are not affected.
137
+
138
+ When an external API returns rate-limit headers (e.g. `X-RateLimit-Remaining`, `X-RateLimit-Reset`), you can temporarily override a registered limit to match the real window:
139
+
140
+ ```ruby
141
+ Hanikamu::RateLimit.register_temporary_limit(:external_api, remaining: 175, reset: 60)
142
+ ```
143
+
144
+ This stores a Redis counter with `remaining` requests allowed and a TTL of `reset` seconds. While the override is active, the fixed-window counter is used instead of the sliding window. When the TTL expires, the original registered limit resumes automatically.
145
+
146
+ Behavior when the override is exhausted (`remaining` reaches 0):
147
+
148
+ - If the remaining TTL exceeds `max_wait_time`, a `RateLimitError` is raised **immediately** — no polling occurs because the fixed-window quota won't reset until the TTL expires.
149
+ - If the remaining TTL is within `max_wait_time`, the limiter polls until the override expires and falls back to the sliding window.
150
+
151
+ This differs from the sliding window, which always polls in short intervals since entries continuously slide out of the window.
152
+
153
+ Typical usage in an API client:
154
+
155
+ ```ruby
156
+ class ExternalApiClient
157
+ extend Hanikamu::RateLimit::Mixin
158
+
159
+ limit_method :call, registry: :external_api
160
+
161
+ def call
162
+ response = http_client.get("/endpoint")
163
+
164
+ if response.headers["X-RateLimit-Remaining"]
165
+ Hanikamu::RateLimit.register_temporary_limit(
166
+ :external_api,
167
+ remaining: response.headers["X-RateLimit-Remaining"],
168
+ reset: response.headers["X-RateLimit-Reset"]
169
+ )
170
+ end
171
+
172
+ response
173
+ end
174
+ end
175
+ ```
176
+
177
+ ### Class methods
178
+
179
+ To rate limit class methods, apply the mixin to the singleton class:
180
+
181
+ ```ruby
182
+ class MyService
183
+ class << self
184
+ extend Hanikamu::RateLimit::Mixin
185
+
186
+ limit_method :call, rate: 5, interval: 1.0
187
+
188
+ def call
189
+ # work
190
+ end
191
+ end
192
+ end
193
+ ```
194
+
195
+ You can also use registered limits:
196
+
197
+ ```ruby
198
+ Hanikamu::RateLimit.configure do |config|
199
+ config.register_limit(:external_api, rate: 5, interval: 1.0)
200
+ end
201
+
202
+ class MyService
203
+ class << self
204
+ extend Hanikamu::RateLimit::Mixin
205
+
206
+ limit_method :call, registry: :external_api
207
+
208
+ def call
209
+ # work
210
+ end
211
+ end
212
+ end
213
+
214
+ ```
215
+
123
216
  ## Error Handling
124
217
 
125
218
  If Redis is unavailable, `RateQueue#shift` logs a warning and returns `nil`.
@@ -127,13 +220,13 @@ If Redis is unavailable, `RateQueue#shift` logs a warning and returns `nil`.
127
220
  ## Testing
128
221
 
129
222
  ```bash
130
- bundle exec rspec
223
+ make rspec
131
224
  ```
132
225
 
133
226
  ## Development
134
227
 
135
228
  ```bash
136
- bundle exec rake
229
+ make shell
137
230
  ```
138
231
 
139
232
  ## License
@@ -3,36 +3,87 @@
3
3
  module Hanikamu
4
4
  module RateLimit
5
5
  module Mixin
6
- def limit_method(method, rate:, interval: 60, **options, &)
7
- queue = build_queue(rate, interval, method, options, &)
8
- install_rate_limited_method(method, queue)
9
- end
6
+ def limit_method(
7
+ method,
8
+ registry: nil,
9
+ rate: nil,
10
+ interval: nil,
11
+ check_interval: nil,
12
+ max_wait_time: nil,
13
+ &
14
+ )
15
+ if registry
16
+ validate_registry_only!(rate, interval, check_interval, max_wait_time)
17
+ queue = build_queue_from_registry(method, registry, &)
18
+ else
19
+ validate_inline_options!(rate, interval)
20
+ interval ||= 60
21
+ queue = build_queue(
22
+ rate,
23
+ interval,
24
+ method,
25
+ check_interval: check_interval,
26
+ max_wait_time: max_wait_time,
27
+ &
28
+ )
29
+ end
10
30
 
11
- def limit_with(method, registry:, **overrides, &)
12
- registry_config = Hanikamu::RateLimit.fetch_limit(registry)
13
- merged = registry_config.merge(overrides.compact)
14
- rate = merged.fetch(:rate)
15
- interval = merged.fetch(:interval)
16
- options = merged.slice(:check_interval, :max_wait_time, :key_prefix)
17
- queue = build_queue(rate, interval, method, options, &)
18
31
  install_rate_limited_method(method, queue)
19
32
  end
20
33
 
21
34
  private
22
35
 
23
- def build_queue(rate, interval, method, options, &)
36
+ def build_queue(
37
+ rate,
38
+ interval,
39
+ method,
40
+ key_prefix: nil,
41
+ check_interval: nil,
42
+ max_wait_time: nil,
43
+ override_key: nil,
44
+ &
45
+ )
24
46
  Hanikamu::RateLimit::RateQueue.new(
25
47
  rate,
26
48
  interval: interval,
27
49
  klass_name: name,
28
50
  method: method,
29
- key_prefix: options[:key_prefix],
30
- check_interval: options.fetch(:check_interval, Hanikamu::RateLimit.config.check_interval),
31
- max_wait_time: options.fetch(:max_wait_time, Hanikamu::RateLimit.config.max_wait_time),
51
+ key_prefix: key_prefix,
52
+ override_key: override_key,
53
+ check_interval: check_interval.nil? ? Hanikamu::RateLimit.config.check_interval : check_interval,
54
+ max_wait_time: max_wait_time.nil? ? Hanikamu::RateLimit.config.max_wait_time : max_wait_time,
55
+ &
56
+ )
57
+ end
58
+
59
+ def build_queue_from_registry(method, registry, &)
60
+ registry_config = Hanikamu::RateLimit.fetch_limit(registry)
61
+ rate = registry_config.fetch(:rate)
62
+ interval = registry_config.fetch(:interval)
63
+ build_queue(
64
+ rate,
65
+ interval,
66
+ method,
67
+ key_prefix: registry_config[:key_prefix],
68
+ check_interval: registry_config[:check_interval],
69
+ max_wait_time: registry_config[:max_wait_time],
70
+ override_key: Hanikamu::RateLimit.override_key_for(registry),
32
71
  &
33
72
  )
34
73
  end
35
74
 
75
+ def validate_registry_only!(rate, interval, check_interval, max_wait_time)
76
+ return unless rate || interval || !check_interval.nil? || !max_wait_time.nil?
77
+
78
+ raise ArgumentError, "registry: must be used alone"
79
+ end
80
+
81
+ def validate_inline_options!(rate, _interval)
82
+ return if rate
83
+
84
+ raise ArgumentError, "Either registry: or rate: must be provided"
85
+ end
86
+
36
87
  def install_rate_limited_method(method, queue)
37
88
  mixin = Module.new do
38
89
  rate_queue = queue
@@ -9,11 +9,30 @@ module Hanikamu
9
9
  KEY_PREFIX = "hanikamu:rate_limit:rate_queue"
10
10
  LUA_SCRIPT = <<~LUA
11
11
  local key = KEYS[1]
12
+ local override_key = KEYS[2]
12
13
  local now = tonumber(ARGV[1])
13
14
  local interval = tonumber(ARGV[2])
14
15
  local rate = tonumber(ARGV[3])
15
16
  local member = ARGV[4]
16
17
 
18
+ if override_key and override_key ~= "" then
19
+ local override_val = redis.call("GET", override_key)
20
+ if override_val then
21
+ local remaining = tonumber(override_val)
22
+ if remaining then
23
+ local ttl = redis.call("TTL", override_key)
24
+ if ttl > 0 then
25
+ if remaining > 0 then
26
+ redis.call("DECR", override_key)
27
+ return {1, 0, 0}
28
+ else
29
+ return {0, ttl, 1}
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
17
36
  redis.call("ZREMRANGEBYSCORE", key, 0, now - interval)
18
37
  local count = redis.call("ZCARD", key)
19
38
 
@@ -33,25 +52,39 @@ module Hanikamu
33
52
  return {0, interval}
34
53
  LUA
35
54
 
36
- def initialize(rate, klass_name:, method:, interval: 60, **options, &block)
55
+ def initialize(
56
+ rate,
57
+ klass_name:,
58
+ method:,
59
+ interval: 60,
60
+ key_prefix: nil,
61
+ override_key: nil,
62
+ check_interval: nil,
63
+ max_wait_time: nil,
64
+ &block
65
+ )
37
66
  @rate = rate
38
67
  @interval = interval.to_f
39
68
  @klass_name = klass_name
40
69
  @method = method
41
- @key_prefix = options[:key_prefix]
42
- @check_interval = options.fetch(:check_interval, Hanikamu::RateLimit.config.check_interval)
43
- @max_wait_time = options.fetch(:max_wait_time, Hanikamu::RateLimit.config.max_wait_time)
70
+ @key_prefix = key_prefix
71
+ @override_key = override_key&.to_s
72
+ @check_interval = check_interval.nil? ? Hanikamu::RateLimit.config.check_interval : check_interval
73
+ @max_wait_time = max_wait_time.nil? ? Hanikamu::RateLimit.config.max_wait_time : max_wait_time
44
74
  @block = block
45
75
  end
46
76
 
47
77
  def shift
48
78
  start_time = current_time
49
-
50
79
  loop do
51
- allowed, sleep_time = attempt_shift(start_time)
80
+ allowed, sleep_time, is_override = attempt_shift(start_time)
52
81
 
53
82
  return if allowed == 1
54
83
 
84
+ if is_override == 1 && @max_wait_time && sleep_time.to_f > @max_wait_time
85
+ raise Hanikamu::RateLimit::RateLimitError, "Max wait time exceeded"
86
+ end
87
+
55
88
  handle_sleep(sleep_time)
56
89
  end
57
90
  rescue Redis::BaseError => e
@@ -72,6 +105,12 @@ module Hanikamu
72
105
  end
73
106
  end
74
107
 
108
+ def redis_keys
109
+ return [redis_key] if @override_key.nil? || @override_key.empty?
110
+
111
+ [redis_key, @override_key]
112
+ end
113
+
75
114
  def attempt_shift(start_time)
76
115
  now = current_time
77
116
  elapsed = now - start_time
@@ -84,11 +123,7 @@ module Hanikamu
84
123
  end
85
124
 
86
125
  def eval_script(now, member)
87
- redis.evalsha(
88
- lua_sha,
89
- keys: [redis_key],
90
- argv: [now, @interval, @rate, member]
91
- )
126
+ redis.evalsha(lua_sha, keys: redis_keys, argv: [now, @interval, @rate, member])
92
127
  rescue Redis::CommandError => e
93
128
  return reload_script_and_retry(now, member) if e.message.include?("NOSCRIPT")
94
129
 
@@ -97,11 +132,7 @@ module Hanikamu
97
132
 
98
133
  def reload_script_and_retry(now, member)
99
134
  @lua_sha = redis.script(:load, LUA_SCRIPT)
100
- redis.evalsha(
101
- lua_sha,
102
- keys: [redis_key],
103
- argv: [now, @interval, @rate, member]
104
- )
135
+ redis.evalsha(lua_sha, keys: redis_keys, argv: [now, @interval, @rate, member])
105
136
  end
106
137
 
107
138
  def handle_sleep(sleep_time)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hanikamu
4
4
  module RateLimit
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -18,23 +18,61 @@ module Hanikamu
18
18
  class << self
19
19
  def configure(&block)
20
20
  super do |config|
21
- config.define_singleton_method(:register_limit) do |name, **options|
22
- Hanikamu::RateLimit.register_limit(name, **options)
21
+ config.define_singleton_method(:register_limit) do |
22
+ name,
23
+ rate:,
24
+ interval:,
25
+ check_interval: nil,
26
+ max_wait_time: nil
27
+ |
28
+ Hanikamu::RateLimit.register_limit(
29
+ name,
30
+ rate: rate,
31
+ interval: interval,
32
+ check_interval: check_interval,
33
+ max_wait_time: max_wait_time
34
+ )
23
35
  end
24
36
  block&.call(config)
25
37
  end
26
38
  end
27
39
 
28
- def register_limit(name, **options)
29
- registry.register(normalize_name(name), normalize_registry_options(name, options))
40
+ def register_limit(name, rate:, interval:, check_interval: nil, max_wait_time: nil)
41
+ registry.register(
42
+ normalize_name(name),
43
+ normalize_registry_options(
44
+ name,
45
+ rate: rate,
46
+ interval: interval,
47
+ check_interval: check_interval,
48
+ max_wait_time: max_wait_time
49
+ )
50
+ )
30
51
  end
31
52
 
32
53
  def fetch_limit(name)
33
54
  registry.resolve(normalize_name(name))
34
- rescue Dry::Container::Error
55
+ rescue Dry::Container::Error, KeyError
35
56
  raise ArgumentError, "Unknown registered limit: #{name}"
36
57
  end
37
58
 
59
+ def register_temporary_limit(name, remaining:, reset:)
60
+ fetch_limit(name) # raise if not registered
61
+ remaining_value = Integer(remaining, exception: false)
62
+ reset_value = Integer(reset, exception: false)
63
+ return false if remaining_value.nil? || reset_value.nil?
64
+ return false if remaining_value.negative? || reset_value <= 0
65
+
66
+ key = override_key_for(name)
67
+ redis_client.set(key, remaining_value, ex: reset_value)
68
+ true
69
+ end
70
+
71
+ def override_key_for(name)
72
+ normalized = normalize_name(name)
73
+ "#{RateQueue::KEY_PREFIX}:registry:#{normalized}:override"
74
+ end
75
+
38
76
  def reset_registry!
39
77
  @registry = Dry::Container.new
40
78
  end
@@ -46,16 +84,20 @@ module Hanikamu
46
84
  private
47
85
 
48
86
  def normalize_name(name)
49
- name.to_sym
87
+ normalized = name.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_+|_+$/, "")
88
+ normalized.to_sym
89
+ end
90
+
91
+ def redis_client
92
+ @redis_client ||= Redis.new(url: config.redis_url)
50
93
  end
51
94
 
52
- def normalize_registry_options(name, options)
53
- rate = options.fetch(:rate)
54
- interval = options.fetch(:interval)
55
- key_prefix = options[:key_prefix] || "#{RateQueue::KEY_PREFIX}:registry:#{name}"
95
+ def normalize_registry_options(name, rate:, interval:, check_interval:, max_wait_time:)
96
+ normalized = normalize_name(name)
97
+ key_prefix = "#{RateQueue::KEY_PREFIX}:registry:#{normalized}"
56
98
  registry_options = { rate: rate, interval: interval, key_prefix: key_prefix }
57
- registry_options[:check_interval] = options[:check_interval] unless options[:check_interval].nil?
58
- registry_options[:max_wait_time] = options[:max_wait_time] unless options[:max_wait_time].nil?
99
+ registry_options[:check_interval] = check_interval unless check_interval.nil?
100
+ registry_options[:max_wait_time] = max_wait_time unless max_wait_time.nil?
59
101
  registry_options
60
102
  end
61
103
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanikamu-rate-limit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolai Seerup