rails_rate_limit 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e681ccbf67051878bd884a7a55db8650056d4107a78b6c5c8a40d5bd5576e50e
4
+ data.tar.gz: 3ce8f1ddde50ca6cf278f3431b1e263d147631c4081be305447a57d38d3f4bd4
5
+ SHA512:
6
+ metadata.gz: e77b8544870e4628b9103c017903ed151eb6aa9f501adae15fbdb13a60f5bf79fa62463a6bcdcd856868cd8721e56f7504ce6f9ad4c1dd1f2789343358d4c517
7
+ data.tar.gz: d9c0ead4ac6e247c0516e6a31872ec2094efde122226809c51db1761698fcc16b93aea8e5128cb1de06e5f6ace53442ef1f0689248ae42858f4818fb244c1c1b
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Kasvit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # Rails Rate Limit
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/rails_rate_limit.svg)](https://badge.fury.io/rb/rails_rate_limit)
4
+ [![Build Status](https://github.com/kasvit/rails_rate_limit/workflows/Ruby/badge.svg)](https://github.com/kasvit/rails_rate_limit/actions)
5
+
6
+ A flexible and robust rate limiting solution for Ruby on Rails applications. The gem implements a sliding window log algorithm, which means it tracks the exact timestamp of each request and calculates the count within a sliding time window. This provides more accurate rate limiting compared to fixed window approaches.
7
+
8
+ For example, if you set a limit of 100 requests per hour, and a user makes 100 requests at 2:30 PM, they won't be able to make another request until some of those requests "expire" after 2:30 PM the next hour. This prevents the common issue with fixed windows where users could potentially make 200 requests around the window boundary.
9
+
10
+ Supports rate limiting for both HTTP requests and method calls, with multiple storage backends (Redis, Memcached, Memory).
11
+ Inspired by https://github.com/rails/rails/blob/8-0-sec/actionpack/lib/action_controller/metal/rate_limiting.rb.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'rails_rate_limit'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ ```bash
24
+ $ bundle install
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Create an initializer `config/initializers/rails_rate_limit.rb`:
30
+
31
+ ```ruby
32
+ RailsRateLimit.configure do |config|
33
+ # Required: Choose your default storage backend
34
+ config.default_store = :redis # Available options: :redis, :memcached, :memory
35
+
36
+ # Optional: Configure Redis connection (required if using Redis store)
37
+ config.redis_connection = Redis.new(
38
+ url: ENV['REDIS_URL'],
39
+ timeout: 1,
40
+ reconnect_attempts: 2
41
+ )
42
+
43
+ # Optional: Configure Memcached connection (required if using Memcached store)
44
+ config.memcached_connection = Dalli::Client.new(
45
+ ENV['MEMCACHED_URL'],
46
+ { expires_in: 1.day, compress: true }
47
+ )
48
+
49
+ # Optional: Configure logging
50
+ config.logger = Rails.logger
51
+
52
+ # Optional: Configure default handler for controllers (HTTP requests)
53
+ config.default_on_controller_exceeded = -> {
54
+ render json: {
55
+ error: "Too many requests",
56
+ retry_after: response.headers["Retry-After"]
57
+ }, status: :too_many_requests
58
+ }
59
+
60
+ # Optional: Configure default handler for methods
61
+ config.default_on_method_exceeded = -> {
62
+ # Your default logic for methods
63
+ # For example: log the event, notify admins, etc.
64
+ Rails.logger.warn("Rate limit exceeded for #{self.class.name}")
65
+ }
66
+ end
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ ### Rate Limiting Controllers
72
+
73
+ Include the module and set rate limits for your controllers:
74
+
75
+ ```ruby
76
+ class ApiController < ApplicationController
77
+ include RailsRateLimit::Controller # include this module
78
+
79
+ # Basic usage - limit all actions
80
+ set_rate_limit limit: 100, # Maximum requests allowed
81
+ period: 1.minute # Time window for the limit
82
+
83
+ # Advanced usage - limit specific actions with all options
84
+ set_rate_limit only: [:create, :update], # Only these actions (optional)
85
+ except: [:index, :show], # Exclude these actions (optional)
86
+ limit: 50, # Maximum requests allowed
87
+ period: 1.hour, # Time window for the limit
88
+ by: -> { current_user&.id || request.remote_ip }, # Request identifier
89
+ store: :redis, # Override default store
90
+ on_exceeded: -> { # Custom error handler
91
+ render json: {
92
+ error: 'Custom error message',
93
+ plan_limit: current_user.plan.limit,
94
+ upgrade_url: pricing_url
95
+ }, status: :too_many_requests
96
+ }
97
+ end
98
+ ```
99
+
100
+ ### Rate Limiting Methods
101
+
102
+ You can limit any method calls in your classes:
103
+
104
+ ```ruby
105
+ class ApiClient
106
+ include RailsRateLimit::Klass # include this module
107
+
108
+ def make_request
109
+ # Your API call logic here
110
+ end
111
+
112
+ # IMPORTANT: set_rate_limit must be called AFTER method definition
113
+ # Basic usage
114
+ set_rate_limit :make_request,
115
+ limit: 100,
116
+ period: 1.minute
117
+
118
+ # Advanced usage with all options
119
+ set_rate_limit :another_method,
120
+ limit: 10, # Maximum calls allowed
121
+ period: 1.hour, # Time window for the limit
122
+ by: -> { "client:#{id}" }, # Method call identifier
123
+ store: :memcached, # Override default store
124
+ on_exceeded: -> { # Custom error handler
125
+ notify_admin
126
+ log_exceeded_event
127
+ enqueue_retry_job
128
+ }
129
+ end
130
+ ```
131
+
132
+ ### Available Options
133
+
134
+ For both controllers and methods:
135
+ - `limit`: (Required) Maximum number of requests/calls allowed
136
+ - `period`: (Required) Time period for the limit (in seconds or ActiveSupport::Duration)
137
+ - `by`: (Optional) Lambda/Proc to generate unique identifier
138
+ - Default for controllers: `request.remote_ip`
139
+ - Default for methods: `"#{class_name}:#{id || object_id}"`
140
+ - `store`: (Optional) Override default storage backend (`:redis`, `:memcached`, `:memory`)
141
+ - `on_exceeded`: (Optional) Custom handler for rate limit exceeded
142
+
143
+ Additional options for controllers:
144
+ - `only`: (Optional) Array of action names to limit
145
+ - `except`: (Optional) Array of action names to exclude
146
+
147
+ ### Rate Limit Exceeded Handling
148
+
149
+ The gem provides different default behaviors for controllers and methods:
150
+
151
+ 1. For controllers (HTTP requests):
152
+ - The `on_exceeded` handler (or `default_on_controller_exceeded` if not specified) is called
153
+ - By default, returns HTTP 429 with a JSON error message
154
+ - Headers are automatically added with limit information
155
+ - The handler's return value is used (usually render/redirect)
156
+
157
+ 2. For methods:
158
+ - The `on_exceeded` handler (or `default_on_method_exceeded` if not specified) is called
159
+ - The event is logged if a logger is configured
160
+ - Returns `nil` after handler execution to indicate no result
161
+ - No exception is raised, making it easier to handle in your code
162
+
163
+ ### HTTP Headers
164
+
165
+ For controller rate limiting, the following headers are automatically added:
166
+ - `X-RateLimit-Limit`: Maximum requests allowed
167
+ - `X-RateLimit-Remaining`: Remaining requests in current period
168
+ - `X-RateLimit-Reset`: Time when the current period will reset (Unix timestamp)
169
+
170
+ ## Storage Backends
171
+
172
+ ### Redis
173
+ - Requires the `redis-rails` gem
174
+ - Best for distributed systems
175
+ - Automatic cleanup of expired data
176
+ - Atomic operations ensure accuracy
177
+ - Recommended for production use
178
+
179
+ ### Memcached
180
+ - Requires the `dalli` gem
181
+ - Good balance of performance and features
182
+ - Automatic cleanup via TTL
183
+ - Works well in distributed environments
184
+ - Good option if you're already using Memcached
185
+
186
+ ### Memory
187
+ - No additional dependencies
188
+ - Perfect for development or single-server setups
189
+ - Data is lost on server restart
190
+ - Not suitable for distributed systems
191
+ - Thread-safe implementation
192
+
193
+ ## Development
194
+
195
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
196
+
197
+ ## Contributing
198
+
199
+ Bug reports and pull requests are welcome on GitHub at https://github.com/kasvit/rails_rate_limit. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
200
+
201
+ ## License
202
+
203
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/rails_rate_limit/configuration.rb
4
+ module RailsRateLimit
5
+ class Configuration
6
+ attr_accessor :default_store, :redis_connection, :memcached_connection, :logger
7
+ attr_reader :default_on_controller_exceeded, :default_on_method_exceeded
8
+
9
+ def initialize
10
+ @default_store = :redis
11
+ @redis_connection = nil
12
+ @memcached_connection = nil
13
+ @logger = nil
14
+ set_default_handlers
15
+ end
16
+
17
+ def default_on_controller_exceeded=(handler)
18
+ raise ArgumentError, "default_on_controller_exceeded must be a Proc" unless handler.is_a?(Proc)
19
+
20
+ @default_on_controller_exceeded = handler
21
+ end
22
+
23
+ def default_on_method_exceeded=(handler)
24
+ raise ArgumentError, "default_on_method_exceeded must be a Proc" unless handler.is_a?(Proc)
25
+
26
+ @default_on_method_exceeded = handler
27
+ end
28
+
29
+ private
30
+
31
+ def set_default_handlers
32
+ @default_on_controller_exceeded = lambda {
33
+ render json: {
34
+ error: "Too many requests",
35
+ retry_after: response.headers["Retry-After"]
36
+ }, status: :too_many_requests
37
+ }
38
+
39
+ @default_on_method_exceeded = -> { nil }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module RailsRateLimit
6
+ module Controller
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def set_rate_limit(limit:, period:, by: nil, on_exceeded: nil, store: nil, **options)
11
+ Validations.validate_options!(limit: limit, period: period, by: by, on_exceeded: on_exceeded, store: store)
12
+
13
+ before_action(options) do |controller|
14
+ limiter = RateLimiter.new(
15
+ context: controller,
16
+ by: by,
17
+ limit: limit,
18
+ period: period.to_i,
19
+ store: store
20
+ )
21
+
22
+ begin
23
+ limiter.perform!
24
+ rescue RailsRateLimit::RateLimitExceeded
25
+ handler = on_exceeded.nil? ? RailsRateLimit.configuration.default_on_controller_exceeded : on_exceeded
26
+ controller.instance_exec(&handler)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ class RateLimitExceeded < StandardError; end
5
+ class StoreError < StandardError; end
6
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ module Klass
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def set_rate_limit(method_name, limit:, period:, by: nil, store: nil, on_exceeded: nil)
9
+ Validations.validate_options!(limit: limit, period: period, by: by, store: store)
10
+
11
+ original_method = instance_method(method_name)
12
+
13
+ define_method(method_name) do |*args, &block|
14
+ limiter = RateLimiter.new(
15
+ context: self,
16
+ by: by || -> { "#{self.class.name}:#{respond_to?(:id) ? id : object_id}" },
17
+ limit: limit,
18
+ period: period.to_i,
19
+ store: store
20
+ )
21
+
22
+ begin
23
+ limiter.perform!
24
+ original_method.bind(self).call(*args, &block)
25
+ rescue RailsRateLimit::RateLimitExceeded
26
+ handler = on_exceeded.nil? ? RailsRateLimit.configuration.default_on_method_exceeded : on_exceeded
27
+ instance_exec(&handler)
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ class Monitoring
5
+ def initialize(logger:)
6
+ @logger = logger
7
+ end
8
+
9
+ def log_exceeded(key:, limit:, period:)
10
+ return unless @logger
11
+
12
+ @logger.warn(
13
+ "Rate limit exceeded for #{key}. " \
14
+ "Limit: #{limit} requests per #{period} seconds"
15
+ )
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ class RateLimiter
5
+ attr_reader :remaining_requests, :reset_time
6
+
7
+ def initialize(context:, by:, limit:, period:, store:)
8
+ @context = context
9
+ @by = by
10
+ @limit = limit
11
+ @period = period
12
+ @store_name = store || RailsRateLimit.configuration.default_store
13
+
14
+ validate_runtime_values!
15
+
16
+ @store = resolve_store
17
+ @key = resolve_key
18
+ @limit_value = resolve_limit
19
+ setup_monitoring
20
+ end
21
+
22
+ def perform!
23
+ if rate_limit_exceeded?
24
+ log_rate_limit_exceeded
25
+ add_rate_limit_headers
26
+ raise RateLimitExceeded
27
+ end
28
+
29
+ record_request
30
+ add_rate_limit_headers
31
+ true
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :context, :by, :limit, :period, :store, :key, :limit_value, :store_name
37
+
38
+ def validate_runtime_values!
39
+ limit_value = @limit.is_a?(Proc) ? context.instance_exec(&@limit) : @limit
40
+
41
+ unless limit_value.is_a?(Integer) && limit_value.positive?
42
+ raise ArgumentError, "Limit must evaluate to a positive integer, got: #{limit_value}"
43
+ end
44
+
45
+ return if @period.positive?
46
+
47
+ raise ArgumentError, "Period must be positive, got: #{@period}"
48
+ end
49
+
50
+ def resolve_key
51
+ return context.request.remote_ip if by.nil?
52
+
53
+ by.is_a?(Proc) ? context.instance_exec(&by) : by
54
+ end
55
+
56
+ def resolve_limit
57
+ limit.is_a?(Proc) ? context.instance_exec(&limit) : limit
58
+ end
59
+
60
+ def resolve_store
61
+ Stores::Base.resolve(store_name)
62
+ end
63
+
64
+ def rate_limit_exceeded?
65
+ current_count = store.count_requests(cache_key, period)
66
+ @remaining_requests = [limit_value - current_count, 0].max
67
+ @reset_time = Time.now + period
68
+ current_count >= limit_value
69
+ end
70
+
71
+ def record_request
72
+ store.record_request(cache_key, period)
73
+ end
74
+
75
+ def cache_key
76
+ @cache_key ||= "rate_limit:#{key}"
77
+ end
78
+
79
+ def add_rate_limit_headers
80
+ return unless context.respond_to?(:response)
81
+
82
+ context.response.headers["X-RateLimit-Limit"] = limit_value.to_s
83
+ context.response.headers["X-RateLimit-Remaining"] = remaining_requests.to_s
84
+ context.response.headers["X-RateLimit-Reset"] = reset_time.to_i.to_s
85
+ context.response.headers["Retry-After"] = period.to_s if remaining_requests.zero?
86
+ end
87
+
88
+ def setup_monitoring
89
+ @monitor = Monitoring.new(
90
+ logger: RailsRateLimit.configuration.logger
91
+ )
92
+ end
93
+
94
+ def log_rate_limit_exceeded
95
+ @monitor.log_exceeded(
96
+ key: key,
97
+ limit: limit_value,
98
+ period: period
99
+ )
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ module Stores
5
+ class Base
6
+ def self.resolve(store_name)
7
+ case store_name.to_sym
8
+ when :redis
9
+ Redis.new
10
+ when :memory
11
+ Memory.instance
12
+ when :memcached
13
+ Memcached.new
14
+ else
15
+ raise StoreError, "Unsupported store: #{store_name}"
16
+ end
17
+ end
18
+
19
+ def count_requests(key, period)
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def record_request(key, period)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ private
28
+
29
+ def cache_key(key)
30
+ "rate_limit:#{key}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ module Stores
5
+ class Memcached < Base
6
+ def initialize
7
+ @memcached = RailsRateLimit.configuration.memcached_connection
8
+ raise "Memcached connection not configured" unless @memcached
9
+ end
10
+
11
+ def count_requests(key, period)
12
+ now = current_time
13
+ min_time = now - period
14
+ key = cache_key(key)
15
+
16
+ timestamps = get_timestamps(key)
17
+ valid_timestamps = cleanup_old_requests(timestamps, min_time)
18
+
19
+ count = valid_timestamps.size
20
+
21
+ if valid_timestamps.empty?
22
+ begin
23
+ @memcached.delete(key)
24
+ rescue StandardError
25
+ nil
26
+ end
27
+ 0
28
+ else
29
+ begin
30
+ @memcached.set(key, valid_timestamps, period)
31
+ rescue StandardError
32
+ nil
33
+ end
34
+ count
35
+ end
36
+ rescue StandardError => e
37
+ RailsRateLimit.logger&.error("RailsRateLimit::Stores::Memcached#count_requests error: #{e.message}")
38
+ 0
39
+ end
40
+
41
+ def record_request(key, period)
42
+ now = current_time
43
+ min_time = now - period
44
+ key = cache_key(key)
45
+
46
+ timestamps = get_timestamps(key)
47
+ timestamps = cleanup_old_requests(timestamps, min_time)
48
+ timestamps << now
49
+ count = timestamps.size
50
+
51
+ begin
52
+ @memcached.set(key, timestamps, period)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ count
57
+ rescue StandardError => e
58
+ RailsRateLimit.logger&.error("RailsRateLimit::Stores::Memcached#record_request error: #{e.message}")
59
+ 0
60
+ end
61
+
62
+ private
63
+
64
+ def get_timestamps(key)
65
+ @memcached.get(key) || []
66
+ rescue StandardError => e
67
+ RailsRateLimit.logger&.error("RailsRateLimit::Stores::Memcached#get_timestamps error: #{e.message}")
68
+ []
69
+ end
70
+
71
+ def cleanup_old_requests(timestamps, min_time)
72
+ timestamps.reject { |timestamp| timestamp < min_time }
73
+ end
74
+
75
+ def current_time
76
+ Time.now.to_f
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ module Stores
5
+ class Memory < Base
6
+ class << self
7
+ def instance
8
+ @instance ||= new
9
+ end
10
+ end
11
+
12
+ def initialize
13
+ @store = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def count_requests(key, period)
18
+ now = current_time
19
+ min_time = now - period
20
+ key = cache_key(key)
21
+
22
+ @mutex.synchronize do
23
+ cleanup_old_requests(key, min_time)
24
+ @store[key]&.size || 0
25
+ end
26
+ rescue StandardError => e
27
+ RailsRateLimit.logger&.error("RailsRateLimit::Stores::Memory#count_requests error: #{e.message}")
28
+ 0
29
+ end
30
+
31
+ def record_request(key, period)
32
+ now = current_time
33
+ min_time = now - period
34
+ key = cache_key(key)
35
+
36
+ @mutex.synchronize do
37
+ cleanup_old_requests(key, min_time)
38
+ @store[key] ||= []
39
+ @store[key] << now
40
+ @store[key].size
41
+ end
42
+ rescue StandardError => e
43
+ RailsRateLimit.logger&.error("RailsRateLimit::Stores::Memory#record_request error: #{e.message}")
44
+ 0
45
+ end
46
+
47
+ def clear
48
+ @mutex.synchronize do
49
+ @store.clear
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def cleanup_old_requests(key, min_time)
56
+ return unless @store[key]
57
+
58
+ @store[key].reject! { |timestamp| timestamp < min_time }
59
+ @store.delete(key) if @store[key].empty?
60
+ end
61
+
62
+ def current_time
63
+ Time.now.to_f
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ module Stores
5
+ class Redis < Base
6
+ def initialize
7
+ @redis = RailsRateLimit.configuration.redis_connection
8
+ raise "Redis connection not configured" unless @redis
9
+ end
10
+
11
+ def count_requests(key, period)
12
+ now = Time.now.to_f
13
+ min_time = now - period
14
+ key = cache_key(key)
15
+
16
+ @redis.multi do |redis|
17
+ redis.zremrangebyscore(key, 0, min_time)
18
+ redis.zcard(key)
19
+ end.last
20
+ rescue StandardError => e
21
+ RailsRateLimit.logger&.error("RailsRateLimit::Stores::Redis#count_requests error: #{e.message}")
22
+ 0
23
+ end
24
+
25
+ def record_request(key, period)
26
+ now = Time.now.to_f
27
+ key = cache_key(key)
28
+
29
+ @redis.multi do |redis|
30
+ redis.zadd(key, now, now)
31
+ redis.expire(key, period)
32
+ end
33
+ rescue StandardError => e
34
+ RailsRateLimit.logger&.error("RailsRateLimit::Stores::Redis#record_request error: #{e.message}")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ module Validations
5
+ class << self
6
+ def validate_options!(options)
7
+ validate_limit!(options[:limit])
8
+ validate_period!(options[:period])
9
+ validate_by!(options[:by]) if options[:by]
10
+ validate_on_exceeded!(options[:on_exceeded]) if options[:on_exceeded]
11
+ validate_store!(options[:store]) if options[:store]
12
+ end
13
+
14
+ private
15
+
16
+ def validate_limit!(limit)
17
+ case limit
18
+ when Numeric
19
+ raise ArgumentError, "limit must be positive" unless limit.positive?
20
+ when Proc
21
+ # Will be evaluated at runtime
22
+ else
23
+ raise ArgumentError, "limit must be a number or Proc"
24
+ end
25
+ end
26
+
27
+ def validate_period!(period)
28
+ return if period.is_a?(Integer) && period.positive?
29
+
30
+ raise ArgumentError, "period must be a positive integer (seconds)"
31
+ end
32
+
33
+ def validate_by!(by)
34
+ return if by.is_a?(String) || by.is_a?(Proc)
35
+
36
+ raise ArgumentError, "by must be a String or Proc"
37
+ end
38
+
39
+ def validate_on_exceeded!(on_exceeded)
40
+ return if on_exceeded.is_a?(Proc)
41
+
42
+ raise ArgumentError, "on_exceeded must be a Proc"
43
+ end
44
+
45
+ def validate_store!(store)
46
+ return if %i[redis memory memcached].include?(store.to_sym)
47
+
48
+ raise ArgumentError, "unsupported store: #{store}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsRateLimit
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_rate_limit/version"
4
+ require "rails_rate_limit/configuration"
5
+ require "rails_rate_limit/controller"
6
+ require "rails_rate_limit/klass"
7
+ require "rails_rate_limit/errors"
8
+ require "rails_rate_limit/monitoring"
9
+ require "rails_rate_limit/rate_limiter"
10
+ require "rails_rate_limit/stores/base"
11
+ require "rails_rate_limit/stores/redis"
12
+ require "rails_rate_limit/stores/memory"
13
+ require "rails_rate_limit/stores/memcached"
14
+ require "rails_rate_limit/validations"
15
+
16
+ module RailsRateLimit
17
+ class << self
18
+ def configure
19
+ yield(configuration)
20
+ end
21
+
22
+ def configuration
23
+ @configuration ||= Configuration.new
24
+ end
25
+
26
+ def logger
27
+ configuration.logger
28
+ end
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_rate_limit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kasvit
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-01-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dalli
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 6.1.7.3
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 6.1.7.3
41
+ - !ruby/object:Gem::Dependency
42
+ name: redis-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: fakeredis
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.21'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.21'
111
+ - !ruby/object:Gem::Dependency
112
+ name: timecop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: Flexible rate limiting with support for Redis, Memcached, and Memory
126
+ storage
127
+ email:
128
+ - kasvit93@gmail.com
129
+ executables: []
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - LICENSE.txt
134
+ - README.md
135
+ - Rakefile
136
+ - lib/rails_rate_limit.rb
137
+ - lib/rails_rate_limit/configuration.rb
138
+ - lib/rails_rate_limit/controller.rb
139
+ - lib/rails_rate_limit/errors.rb
140
+ - lib/rails_rate_limit/klass.rb
141
+ - lib/rails_rate_limit/monitoring.rb
142
+ - lib/rails_rate_limit/rate_limiter.rb
143
+ - lib/rails_rate_limit/stores/base.rb
144
+ - lib/rails_rate_limit/stores/memcached.rb
145
+ - lib/rails_rate_limit/stores/memory.rb
146
+ - lib/rails_rate_limit/stores/redis.rb
147
+ - lib/rails_rate_limit/validations.rb
148
+ - lib/rails_rate_limit/version.rb
149
+ homepage: https://github.com/kasvit/rails_rate_limit
150
+ licenses:
151
+ - MIT
152
+ metadata:
153
+ allowed_push_host: https://rubygems.org
154
+ homepage_uri: https://github.com/kasvit/rails_rate_limit
155
+ source_code_uri: https://github.com/kasvit/rails_rate_limit
156
+ changelog_uri: https://github.com/kasvit/rails_rate_limit/blob/master/CHANGELOG.md
157
+ post_install_message:
158
+ rdoc_options: []
159
+ require_paths:
160
+ - lib
161
+ required_ruby_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: 3.1.0
166
+ required_rubygems_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ requirements: []
172
+ rubygems_version: 3.3.3
173
+ signing_key:
174
+ specification_version: 4
175
+ summary: Flexible rate limiting for Rails applications
176
+ test_files: []