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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +203 -0
- data/Rakefile +12 -0
- data/lib/rails_rate_limit/configuration.rb +42 -0
- data/lib/rails_rate_limit/controller.rb +32 -0
- data/lib/rails_rate_limit/errors.rb +6 -0
- data/lib/rails_rate_limit/klass.rb +34 -0
- data/lib/rails_rate_limit/monitoring.rb +18 -0
- data/lib/rails_rate_limit/rate_limiter.rb +102 -0
- data/lib/rails_rate_limit/stores/base.rb +34 -0
- data/lib/rails_rate_limit/stores/memcached.rb +80 -0
- data/lib/rails_rate_limit/stores/memory.rb +67 -0
- data/lib/rails_rate_limit/stores/redis.rb +38 -0
- data/lib/rails_rate_limit/validations.rb +52 -0
- data/lib/rails_rate_limit/version.rb +5 -0
- data/lib/rails_rate_limit.rb +30 -0
- metadata +176 -0
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
|
+
[](https://badge.fury.io/rb/rails_rate_limit)
|
4
|
+
[](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,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,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,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: []
|