co-limit 0.1.3 → 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: d0298c1dc7c99033e8919babcecbfdc0a25fd25ff05a8ebdc53437b898bc7ee7
4
- data.tar.gz: 63b245b43b37eaa0340298d9b4317a078868fc37862b01d414764e671ef4c84b
3
+ metadata.gz: 6485d06837c5fb0b4b0fad6a0d8d4cdaede3b73dbd056b89af116e39f0be121a
4
+ data.tar.gz: 3d237294612352598260a83054ee370d7dacd297543f257189c1e0d18ea84771
5
5
  SHA512:
6
- metadata.gz: 6b959ef55df27bea377e777d83e2acaad02f166eff5ff3e6140253ab54fb5e4fd0e8d6bcf40f2ea440a20cef7099cae8f3ac468d960ce090b6fa1fbb220b4406
7
- data.tar.gz: 9a3b26e770dda72c4096c20ed601d672faaf4ee26bff9c6cfa155c8ba8319e977c7c0477acc27417b77252b92e250e64782da291d02de5f5c97629ca8e03ad8a
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.3'
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,9 +63,8 @@ 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
- #TODO: Connection creation can be lazy
58
68
  @redis = if host && port && password
59
69
  self.class.send(:create_connection, host: host, port: port, password: password)
60
70
  else
@@ -73,6 +83,7 @@ module Limit
73
83
  raise e
74
84
  end
75
85
 
86
+ self.class.load_script
76
87
  end
77
88
 
78
89
  def allowed?(key)
@@ -80,28 +91,33 @@ module Limit
80
91
  end
81
92
 
82
93
  def get_key(key)
83
- raise NotImplementedError "#{self.class.name} must implement the get_key() method"
94
+ "#{@identifier_prefix}:#{key}"
84
95
  end
85
96
 
86
97
 
87
98
  protected
88
99
 
100
+ def required_keys
101
+ self.class::REQUIRED_KEYS
102
+ end
103
+
89
104
  def get_current_limit(key)
90
105
  limit_data = @limit_calculator.call(key)
91
106
 
92
- unless limit_data.is_a?(Hash) && limit_data[:max_requests].is_a?(Integer) && limit_data[:max_requests].positive? &&
93
- limit_data[:window_seconds].is_a?(Integer) && limit_data[:window_seconds].positive?
94
-
95
- 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
96
112
  end
97
113
 
98
114
  limit_data
99
115
  end
100
116
 
101
- def redis_pipeline(&block)
117
+ def eval_sha(key, argv)
102
118
  attempts ||= 0
103
119
  begin
104
- @redis.pipelined { |pipe| block.call(pipe) }
120
+ @redis.evalsha(self.class.script_sha, key, argv)
105
121
  rescue Redis::CannotConnectError, Redis::TimeoutError, Redis::ConnectionError => e
106
122
  if attempts < 3
107
123
  @logger.warn(e.message)
@@ -111,16 +127,18 @@ module Limit
111
127
  else
112
128
  @logger.error("Error connecting to Redis: #{e.message}")
113
129
  end
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
114
137
  rescue Redis::BaseError => e
115
138
  @logger.error(e.message)
116
139
  end
117
140
  end
118
141
 
119
- def current_micros
120
- (Time.now.to_f * 1_000_000).to_i
121
- end
122
-
123
-
124
142
  end
125
143
 
126
144
 
@@ -128,60 +146,56 @@ module Limit
128
146
 
129
147
  # Fixed Window Rate Limiter, allows n of request in a fixed window
130
148
  # ALERT: There is a chance of bursts/spike in this method, so use it with caution
131
- class FixedWindowRateLimiter < BaseLimiter
149
+ class FixedWindowRateLimiter < BaseRateLimiter
132
150
  def allowed?(key)
133
151
  limit_data = get_current_limit(key)
134
- max_requests = limit_data[:max_requests]
135
- window_seconds = limit_data[:window_seconds]
136
-
137
- window_key = get_key(key, window_seconds)
138
-
139
- results = redis_pipeline do |pipe|
140
- # This is for simplicity as incr handles both creation and incrementing, rather than waiting on some read
141
- # using the pipeline, the whole operation would also be atomic, as redis is single threaded and both queries are send in one trip
142
- # https://redis.io/docs/latest/develop/use/pipelining/
143
- pipe.incr(window_key)
144
- pipe.expire(window_key, window_seconds)
145
- end
146
-
147
- results[0] <= max_requests
148
- end
149
-
150
- def get_key(key, window_seconds)
151
- time_window = (Time.now.to_i / window_seconds) * window_seconds
152
- "#{@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
153
160
  end
154
161
  end
155
162
 
156
- # RollingWindow Rate limiter implemented using Sliding Log, allows n no of requests in rolling window
157
- class RollingWindowRateLimiter < BaseLimiter
163
+ # RollingWindow Rate limiter, implemented using Sliding Log, allows n no of requests in a rolling window
164
+ class RollingWindowRateLimiter < BaseRateLimiter
158
165
  def allowed?(key)
159
166
  limit_data = get_current_limit(key)
160
- max_requests = limit_data[:max_requests]
161
- window_seconds = limit_data[:window_seconds]
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
162
177
 
163
- set_key = get_key(key)
178
+ # TokenBucketRateLimiter implements the token bucket algorithm that'll allow the steady refill rate
179
+ class TokenBucketRateLimiter < BaseRateLimiter
164
180
 
165
- curr_micros = current_micros
181
+ # limit_calculator: Lambda that takes a key(String) and returns hash:
182
+ # {bucket_capacity: Integer, refill_rate: Integer, refill_interval: Integer}
166
183
 
167
- window_start_micros = curr_micros - (window_seconds*1_000_000)
184
+ REQUIRED_KEYS = %i[bucket_capacity refill_rate refill_interval].freeze
168
185
 
169
- results = redis_pipeline do |pipe|
170
- # uses sorted set
171
- # https://redis.io/glossary/redis-sorted-sets/
172
- pipe.zremrangebyscore(set_key, 0, window_start_micros)
173
- pipe.zadd(set_key, curr_micros, curr_micros.to_s)
174
- pipe.zcard(set_key)
175
- pipe.expire(set_key, window_seconds)
176
- end
177
-
178
- 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
179
197
  end
180
198
 
181
- def get_key(key)
182
- "#{@identifier_prefix}:#{key}"
183
- end
184
199
  end
185
200
 
186
-
187
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.3
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-08-04 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: