co-limit 0.1.2 → 0.1.4

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: 5f54e6c9ae0b841090b1fe2eae972242c4a416eb504de4940cc807a500f7d27a
4
- data.tar.gz: ebc7e45c7c29b425a4391230173f8db3a44c288482a917f2ef8e57be75552a31
3
+ metadata.gz: 6485d06837c5fb0b4b0fad6a0d8d4cdaede3b73dbd056b89af116e39f0be121a
4
+ data.tar.gz: 3d237294612352598260a83054ee370d7dacd297543f257189c1e0d18ea84771
5
5
  SHA512:
6
- metadata.gz: 8535547535b239b227de6f78dfa0c37380e33c77f74db7c85ad91792068847b44f9074b02b3e034f9d94c8c89d517ac884ab18f111aaaca8bdeee02fc774b2ba
7
- data.tar.gz: b37ee6f384eed0728a8b4a29b3271475bbe42607c0d879471081947ec4d2c34ef7d14be28f95b305094cf2cb8c548c6aa3a5a1b77fdc5fbf0eac52711fe0ac53
6
+ metadata.gz: c30142a96da7574db4946c78a37d3716d5f69e4fd5c73a86e6bfffddb30f8c1bacf67206a7b22159c94c5322c5f3be821044a4c1c43ce94051fe9c3f25174611
7
+ data.tar.gz: 02c49b15b93c06208221ff6d4013c31dfe0053f9326c0e22f3d04c187906d17627b1c72e20ee7bea78f7ebedc169ac4074759aa9342c5eff8fef824dcef62d5e
data/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
 
2
2
  # Limit Gem
3
3
 
4
- Gem that provides flexible, Redis-backed rate limiting utilities. It supports both Fixed Window and Rolling Window (Sliding Log) strategies, to easily control the number of allowed requests for a given identifier within a time window.
4
+ Gem that provides flexible, Redis-backed rate limiting utilities. It supports Fixed Window, Rolling Window (Sliding Log), and Token Bucket strategies to control the number of allowed requests for a given identifier within a time window or with a steady refill rate.
5
5
 
6
- You can define rate-limiting rules dynamically using a lambda, and configure Redis via environment variables (REDIS_HOST, REDIS_PORT, REDIS_PASSWORD) or by passing connection details directly.
6
+ You can define rate-limiting rules dynamically using a lambda and configure Redis via environment variables (REDIS_HOST, REDIS_PORT, REDIS_PASSWORD) or bypassing connection details directly.
7
7
 
8
8
  This gem is ideal for APIs, background jobs, or any system that needs simple, efficient throttling logic.
9
9
 
@@ -21,11 +21,23 @@ If you are not using Bundler, you can install the gem directly by running:
21
21
  $ gem install co-limit
22
22
  ```
23
23
 
24
+ ### Supported Rate Limiters:
25
+
26
+ - **Fixed Window Rate Limiter**:
27
+ Allows a specified number of requests within a fixed time window. This method can cause burst traffic as it doesn't account for requests made outside of the window until it resets.
28
+
29
+ - **Rolling Window Rate Limiter**:
30
+ Uses a sliding window mechanism, where only requests made within the last `n` seconds are counted. It provides more consistent traffic flow but can be more resource-intensive.
31
+
32
+ - **Token Bucket Rate Limiter**:
33
+ Implements the classic token bucket algorithm. A bucket with fixed capacity refills tokens at a steady rate, allowing short bursts up to the bucket capacity while enforcing long‑term average throughput.
34
+
24
35
  ## Usage
25
36
 
26
- ### Example Usage
37
+ ### Examples
27
38
 
28
- Here's an example of how to use the rate limiter in your application:
39
+ #### Rolling Window Example
40
+ Here's an example of how to use the rolling window rate limiter in your application:
29
41
 
30
42
  ```ruby
31
43
  sync_limit_calculator = lambda do |key|
@@ -56,6 +68,91 @@ success_count += 1 if allowed
56
68
  puts "Success count: #{success_count}" # Expected to be 11
57
69
  ```
58
70
 
71
+ #### Fixed Window Example
72
+
73
+ ```ruby
74
+ # Map keys to a fixed-window limit of N requests per M seconds
75
+ SITE_LIMITS = {
76
+ x: { max_requests: 10, window_seconds: 5 }, # 10 requests per 5 seconds
77
+ default: { max_requests: 10, window_seconds: 60 }
78
+ }.freeze
79
+
80
+ calc = lambda do |key|
81
+ plan = key.split(":").last.to_sym rescue :default
82
+ SITE_LIMITS.fetch(plan, SITE_LIMITS[:default])
83
+ end
84
+
85
+ limiter = Limit::FixedWindowRateLimiter.new(
86
+ identifier_prefix: "access",
87
+ limit_calculator: calc
88
+ )
89
+
90
+ key = "user123:x" # => will map to { max_requests: 10, window_seconds: 5 }
91
+
92
+ # Consume up to the window limit
93
+ allowed = 0
94
+ 12.times { allowed += 1 if limiter.allowed?(key) }
95
+ puts allowed # => 10
96
+
97
+ # Wait until the next aligned fixed window begins to reset the counter
98
+ window = 5
99
+ now = Time.now
100
+ sleep(window - (now.to_i % window) + 0.5)
101
+
102
+ puts limiter.allowed?(key) # => true (window reset)
103
+ ```
104
+
105
+ #### Token Bucket Example
106
+
107
+ ```ruby
108
+ # Choose a plan via the key suffix, then map it to a token bucket config
109
+ PLANS = {
110
+ basic: { bucket_capacity: 3, refill_rate: 1, refill_interval: 1 }, # 3 burst, ~1 req/s
111
+ premium: { bucket_capacity: 10, refill_rate: 5, refill_interval: 1 } # 10 burst, ~5 req/s
112
+ }.freeze
113
+
114
+ calc = lambda do |key|
115
+ plan = key.split(":").last.to_sym rescue :basic
116
+ PLANS.fetch(plan, PLANS[:basic])
117
+ end
118
+
119
+ limiter = Limit::TokenBucketRateLimiter.new(
120
+ identifier_prefix: "access", # identifier_prefix is used to namespace keys in Redis (consistent across all limiters)
121
+ limit_calculator: calc
122
+ )
123
+
124
+ key = "user123:basic" # the final Redis key will be "access:user123:basic" via identifier_prefix
125
+
126
+ # Use up to capacity immediately
127
+ 3.times { puts limiter.allowed?(key) } # => true, true, true
128
+ puts limiter.allowed?(key) # => false (bucket empty)
129
+
130
+ sleep 1.2 # wait ~1s to refill ~1 token
131
+ puts limiter.allowed?(key) # => true
132
+ ```
133
+
134
+ ### Key Points:
135
+
136
+ - **identifier_prefix**: A namespace prefix for Redis keys (e.g., `"access"`).
137
+ - **limit_calculator**: A `Lambda` that takes a key (e.g., `"user_id:site_name"`) and returns a hash with `max_requests` and `window_seconds`.
138
+
139
+ ### Limit Hash Structure by Limiter
140
+
141
+ Your `limit_calculator` lambda must return a hash whose expected keys depend on the limiter you use:
142
+
143
+ - FixedWindowRateLimiter and RollingWindowRateLimiter expect:
144
+ `{ max_requests: Integer, window_seconds: Integer }`
145
+
146
+ - TokenBucketRateLimiter expects:
147
+ `{ bucket_capacity: Integer, refill_rate: Integer, refill_interval: Integer }`
148
+
149
+ Meaning: `refill_rate` tokens are added every `refill_interval` seconds (e.g., `refill_rate: 1, refill_interval: 1` = 1 token/second; `refill_rate: 3, refill_interval: 2` = 1.5 tokens/second effective rate). All values must be positive integers.
150
+
151
+ Notes:
152
+ - Redis keys are stored as "#{identifier_prefix}:#{key}". Choose concise, stable keys; e.g., identifier_prefix: "access" and key: "user_id:plan" → "access:user_id:plan".
153
+ - Ensure the keys you pass are unique per entity you want to limit (e.g., "access:user_id:plan").
154
+ - For Token Bucket, the Redis TTL is kept around the refill interval so buckets expire when inactive.
155
+
59
156
  ### Redis Configuration
60
157
 
61
158
  You can configure the Redis connection either by passing the connection details as arguments or by setting environment variables.
@@ -82,19 +179,6 @@ You can configure the Redis connection either by passing the connection details
82
179
 
83
180
  In this case, the gem will use these environment variables to establish the Redis connection.
84
181
 
85
- ### Key Points:
86
-
87
- - **identifier_prefix**: A namespace prefix for Redis keys (e.g., `"access"`).
88
- - **limit_calculator**: A `Lambda` that takes a key (e.g., `"user_id:site_name"`) and returns a hash with `max_requests` and `window_seconds`.
89
-
90
- ### Supported Rate Limiters:
91
-
92
- - **Fixed Window Rate Limiter**:
93
- Allows a specified number of requests within a fixed time window. This method can cause burst traffic as it doesn't account for requests made outside of the window until it resets.
94
-
95
- - **Rolling Window Rate Limiter**:
96
- Uses a sliding window mechanism, where only requests made within the last `n` seconds are counted. It provides more consistent traffic flow but can be more resource-intensive.
97
-
98
182
  ## Development
99
183
 
100
184
  After checking out the repo, install the dependencies by running:
data/lib/limit/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Limit
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.4'
5
5
  end
data/lib/limit.rb CHANGED
@@ -9,12 +9,14 @@ module Limit
9
9
  # Base Class, implementing:
10
10
  # - Method to connect with redis
11
11
  # - Signature for limit_calculator
12
- class BaseLimiter
12
+ class BaseRateLimiter
13
13
  attr_reader :limit_calculator, :identifier_prefix
14
14
 
15
15
  @redis = nil
16
16
  @logger = Logger.new($stdout)
17
17
 
18
+ REQUIRED_KEYS = %i[max_requests window_seconds].freeze
19
+
18
20
  class << self
19
21
 
20
22
  def connection
@@ -28,6 +30,14 @@ module Limit
28
30
  @logger ||= Logger.new($stdout)
29
31
  end
30
32
 
33
+ def load_script
34
+ script_name = name.split('::')[1].chomp('RateLimiter')
35
+ script_path = File.expand_path("scripts/#{script_name}.lua", __dir__)
36
+ @script_sha = @redis.script(:load, File.read(script_path))
37
+ end
38
+
39
+ attr_reader :script_sha
40
+
31
41
  private
32
42
 
33
43
  def create_connection(host:, port:, password:)
@@ -44,7 +54,8 @@ module Limit
44
54
  def initialize(identifier_prefix:, limit_calculator:, host: nil, port: nil, password: nil)
45
55
 
46
56
  # @param identifier_prefix: [String] A namespace prefix for redis keys for this limiter instance
47
- # @param limit_calculator: [Lambda] A method that takes a key(String) and returns hash: {max_requests: Integer, window_seconds: Integer}
57
+ # @param limit_calculator: Lambda that takes a key(String) and returns hash: {max_requests: Integer, window_seconds: Integer}
58
+ # But diff implementation can update the hash struct
48
59
 
49
60
  unless identifier_prefix.is_a?(String) && !identifier_prefix.empty?
50
61
  raise ArgumentError, 'identifier_prefix must be a non-empty String'
@@ -52,7 +63,7 @@ module Limit
52
63
 
53
64
  raise ArgumentError, 'limit_calculator must be a Lambda' unless limit_calculator.lambda?
54
65
 
55
- # Will be using the same connection across all instance unless wanted to connect to diff instance of redis
66
+ # Will be using the same connection across all instances unless wanted to connect to diff instance of redis
56
67
 
57
68
  @redis = if host && port && password
58
69
  self.class.send(:create_connection, host: host, port: port, password: password)
@@ -72,6 +83,7 @@ module Limit
72
83
  raise e
73
84
  end
74
85
 
86
+ self.class.load_script
75
87
  end
76
88
 
77
89
  def allowed?(key)
@@ -79,37 +91,54 @@ module Limit
79
91
  end
80
92
 
81
93
  def get_key(key)
82
- raise NotImplementedError "#{self.class.name} must implement the get_key() method"
94
+ "#{@identifier_prefix}:#{key}"
83
95
  end
84
96
 
85
97
 
86
98
  protected
87
99
 
100
+ def required_keys
101
+ self.class::REQUIRED_KEYS
102
+ end
103
+
88
104
  def get_current_limit(key)
89
105
  limit_data = @limit_calculator.call(key)
90
106
 
91
- unless limit_data.is_a?(Hash) && limit_data[:max_requests].is_a?(Integer) && limit_data[:max_requests].positive? &&
92
- limit_data[:window_seconds].is_a?(Integer) && limit_data[:window_seconds].positive?
93
-
94
- raise ArgumentError, "Limit calculator for key '#{key}' returned invalid data: #{limit_data.inspect}. Expected { max_requests: Integer > 0, window_seconds: Integer > 0 }"
107
+ required_keys.all? do |key|
108
+ unless limit_data[key].is_a?(Integer) && limit_data[key].positive?
109
+ raise ArgumentError, "Limit calculator for key '#{key}' returned invalid data: #{limit_data.inspect}.\n
110
+ Expected #{required_keys}"
111
+ end
95
112
  end
96
113
 
97
114
  limit_data
98
115
  end
99
116
 
100
- def redis_pipeline(&block)
117
+ def eval_sha(key, argv)
118
+ attempts ||= 0
101
119
  begin
102
- @redis.pipelined { |pipe| block.call(pipe) }
120
+ @redis.evalsha(self.class.script_sha, key, argv)
121
+ rescue Redis::CannotConnectError, Redis::TimeoutError, Redis::ConnectionError => e
122
+ if attempts < 3
123
+ @logger.warn(e.message)
124
+ @redis = self.class.connection
125
+ attempts += 1
126
+ retry
127
+ else
128
+ @logger.error("Error connecting to Redis: #{e.message}")
129
+ end
103
130
  rescue Redis::CommandError => e
131
+ if e.message.start_with?("NOSCRIPT")
132
+ self.class.load_script
133
+ retry
134
+ else
135
+ @logger.error(e.message)
136
+ end
137
+ rescue Redis::BaseError => e
104
138
  @logger.error(e.message)
105
139
  end
106
140
  end
107
141
 
108
- def current_micros
109
- (Time.now.to_f * 1_000_000).to_i
110
- end
111
-
112
-
113
142
  end
114
143
 
115
144
 
@@ -117,60 +146,56 @@ module Limit
117
146
 
118
147
  # Fixed Window Rate Limiter, allows n of request in a fixed window
119
148
  # ALERT: There is a chance of bursts/spike in this method, so use it with caution
120
- class FixedWindowRateLimiter < BaseLimiter
149
+ class FixedWindowRateLimiter < BaseRateLimiter
121
150
  def allowed?(key)
122
151
  limit_data = get_current_limit(key)
123
- max_requests = limit_data[:max_requests]
124
- window_seconds = limit_data[:window_seconds]
125
-
126
- window_key = get_key(key, window_seconds)
127
-
128
- results = redis_pipeline do |pipe|
129
- # This is for simplicity as incr handles both creation and incrementing, rather than waiting on some read
130
- # using the pipeline, the whole operation would also be atomic, as redis is single threaded and both queries are send in one trip
131
- # https://redis.io/docs/latest/develop/use/pipelining/
132
- pipe.incr(window_key)
133
- pipe.expire(window_key, window_seconds)
134
- end
135
-
136
- results[0] <= max_requests
137
- end
138
-
139
- def get_key(key, window_seconds)
140
- time_window = (Time.now.to_i / window_seconds) * window_seconds
141
- "#{@identifier_prefix}:#{key}:#{time_window.to_s}"
152
+ result = eval_sha(
153
+ [get_key(key)],
154
+ [
155
+ limit_data[:window_seconds],
156
+ limit_data[:max_requests]
157
+ ]
158
+ )
159
+ result == 1
142
160
  end
143
161
  end
144
162
 
145
- # RollingWindow Rate limiter implemented using Sliding Log, allows n no of requests in rolling window
146
- class RollingWindowRateLimiter < BaseLimiter
163
+ # RollingWindow Rate limiter, implemented using Sliding Log, allows n no of requests in a rolling window
164
+ class RollingWindowRateLimiter < BaseRateLimiter
147
165
  def allowed?(key)
148
166
  limit_data = get_current_limit(key)
149
- max_requests = limit_data[:max_requests]
150
- window_seconds = limit_data[:window_seconds]
151
-
152
- set_key = get_key(key)
167
+ result = eval_sha(
168
+ [get_key(key)],
169
+ [
170
+ limit_data[:window_seconds],
171
+ limit_data[:max_requests]
172
+ ]
173
+ )
174
+ result == 1
175
+ end
176
+ end
153
177
 
154
- curr_micros = current_micros
178
+ # TokenBucketRateLimiter implements the token bucket algorithm that'll allow the steady refill rate
179
+ class TokenBucketRateLimiter < BaseRateLimiter
155
180
 
156
- window_start_micros = curr_micros - (window_seconds*1_000_000)
181
+ # limit_calculator: Lambda that takes a key(String) and returns hash:
182
+ # {bucket_capacity: Integer, refill_rate: Integer, refill_interval: Integer}
157
183
 
158
- results = redis_pipeline do |pipe|
159
- # uses sorted set
160
- # https://redis.io/glossary/redis-sorted-sets/
161
- pipe.zremrangebyscore(set_key, 0, window_start_micros)
162
- pipe.zadd(set_key, curr_micros, curr_micros.to_s)
163
- pipe.zcard(set_key)
164
- pipe.expire(set_key, window_seconds)
165
- end
184
+ REQUIRED_KEYS = %i[bucket_capacity refill_rate refill_interval].freeze
166
185
 
167
- results[2] <= max_requests
186
+ def allowed?(key)
187
+ limit_data = get_current_limit(key)
188
+ result = eval_sha(
189
+ [get_key(key)],
190
+ [
191
+ limit_data[:bucket_capacity],
192
+ limit_data[:refill_rate],
193
+ limit_data[:refill_interval]
194
+ ]
195
+ )
196
+ result == 1
168
197
  end
169
198
 
170
- def get_key(key)
171
- "#{@identifier_prefix}:#{key}"
172
- end
173
199
  end
174
200
 
175
-
176
201
  end
@@ -0,0 +1,21 @@
1
+ local key = KEYS[1]
2
+ local window_seconds = tonumber(ARGV[1])
3
+ local max_requests = tonumber(ARGV[2])
4
+
5
+ local curr_time = redis.call('TIME')
6
+ local curr_sec = tonumber(curr_time[1])
7
+
8
+ local time_window = math.floor(curr_sec / window_seconds) * window_seconds
9
+
10
+ local window_key = key .. ":" .. tostring(time_window)
11
+
12
+ -- This is for simplicity as incr handles both creation and incrementing, rather than waiting on some read
13
+ local count = redis.call('incr', window_key)
14
+
15
+ redis.call('expire', window_key, window_seconds)
16
+
17
+ if count <= max_requests then
18
+ return 1
19
+ else
20
+ return 0
21
+ end
@@ -0,0 +1,28 @@
1
+ local key = KEYS[1]
2
+ local window_seconds = tonumber(ARGV[1])
3
+ local max_requests = tonumber(ARGV[2])
4
+
5
+ local time_data = redis.call('TIME')
6
+ local curr_micros = tonumber(time_data[1]) * 1000000 + tonumber(time_data[2])
7
+ local window_start_micros = curr_micros - (window_seconds * 1000000)
8
+
9
+ -- uses sorted set
10
+ -- https://redis.io/glossary/redis-sorted-sets/
11
+ -- Remove old entries
12
+ redis.call('ZREMRANGEBYSCORE', key, 0, window_start_micros)
13
+
14
+ -- Add current request
15
+ redis.call('ZADD', key, curr_micros, tostring(curr_micros))
16
+
17
+ -- Count requests
18
+ local count = redis.call('ZCARD', key)
19
+
20
+ -- Set expiry
21
+ redis.call('EXPIRE', key, window_seconds)
22
+
23
+ if count <= max_requests then
24
+ return 1
25
+ else
26
+ return 0
27
+ end
28
+
@@ -0,0 +1,23 @@
1
+ local key = KEYS[1]
2
+ local bucket_capacity = tonumber(ARGV[1])
3
+ local refill_rate = tonumber(ARGV[2])
4
+ local refill_interval = tonumber(ARGV[3])
5
+
6
+ local time_data = redis.call('TIME')
7
+ local current_time = tonumber(time_data[1]) * 1000000 + tonumber(time_data[2])
8
+
9
+ local bucket_data = redis.call('HMGET', key, 'tokens', 'last_updated')
10
+ local tokens = tonumber(bucket_data[1]) or bucket_capacity
11
+ local last_updated = tonumber(bucket_data[2]) or current_time
12
+
13
+ local elapsed_micros = current_time - last_updated
14
+ local elapsed_seconds = elapsed_micros / 1000000
15
+ local tokens_to_add = math.floor(elapsed_seconds * refill_rate / refill_interval)
16
+ local new_tokens = math.min(bucket_capacity, tokens + tokens_to_add)
17
+
18
+ if new_tokens > 0 then
19
+ redis.call('HMSET', key, 'tokens', new_tokens - 1, 'last_updated', current_time)
20
+ redis.call('EXPIRE', key, refill_interval * 2)
21
+ return 1
22
+ end
23
+ return 0
data/sig/limit/limit.rbs CHANGED
@@ -2,9 +2,11 @@ type redis = untyped
2
2
 
3
3
  module Limit
4
4
 
5
- class BaseLimiter
5
+ class BaseRateLimiter
6
+ REQUIRED_KEYS: Array[Symbol]
6
7
  self.@logger: Logger
7
8
  self.@redis: redis
9
+ self.@script_sha: untyped
8
10
  @logger: Logger
9
11
 
10
12
  @redis: redis | nil
@@ -17,6 +19,10 @@ module Limit
17
19
 
18
20
  def self.logger: -> Logger
19
21
 
22
+ def self.load_script: -> String
23
+
24
+ def self.script_sha: -> String
25
+
20
26
  attr_reader identifier_prefix: String
21
27
  attr_reader limit_calculator: Proc
22
28
 
@@ -32,6 +38,8 @@ module Limit
32
38
 
33
39
  def log: (String, String) -> nil
34
40
 
35
- def redis_pipeline: () { (redis) -> untyped } -> untyped
36
- end
41
+ def eval_sha: (Array[String], Array[Integer]) -> bool
42
+
43
+ def required_keys: -> Array[Symbol]
44
+ end
37
45
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: co-limit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - CosmicOppai
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-05-30 00:00:00.000000000 Z
11
+ date: 2025-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -39,6 +39,9 @@ files:
39
39
  - Steepfile
40
40
  - lib/limit.rb
41
41
  - lib/limit/version.rb
42
+ - lib/scripts/FixedWindow.lua
43
+ - lib/scripts/RollingWindow.lua
44
+ - lib/scripts/TokenBucket.lua
42
45
  - sig/limit/limit.rbs
43
46
  homepage: https://github.com/cosmicoppai/limit
44
47
  licenses: