redis-adequate_rate_limiter 1.0.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: fc796edc36084592000ece83d07d06e1849b42e1f46965607ecc044be9285017
4
+ data.tar.gz: dae9dee9c2cf19a2f0a200f416ed1aed06f93ea63b172c0a6f06c89c43e5effb
5
+ SHA512:
6
+ metadata.gz: 7f06e5c038a2bcaa79974ccb2cc9c56fe1a8badff43ba204738de926ab5f25b3a504d82f378a61014617a39bb8386ba3f92f73b3e97f38dff13dc0572cb9d865
7
+ data.tar.gz: 4c03eaa4ee1e0d0fe3929f3d2a4062d3cd4cd5352a71bb8bfc05b37ba1e91aaf768b0f334ab7c42bd4b8f3d3f185108f64c4625bda9361858eabb3efdcca4162
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @see https://github.com/redis/redis-rb
4
+ class Redis
5
+ # Wrapper for a Lua script that provides smooth, configurable, space-efficient & blazing fast
6
+ # rate limiting.
7
+ #
8
+ # Usage:
9
+ #
10
+ # require 'redis'
11
+ # require 'redis/adequate_rate_limiter'
12
+ #
13
+ # r = Redis.new
14
+ # rate_limiter = Redis::AdequateRateLimiter.new(r)
15
+ # rate_limiter.configure(r, event_type, max_allowed, over_interval, lockout_interval)
16
+ #
17
+ # ...
18
+ # if rate_limiter.allow?(r, event_type, actor)
19
+ # # Count this action and check if it is allowed.
20
+ # ...
21
+ # end
22
+ # ...
23
+ #
24
+ #
25
+ # If `allow?` is invoked on an event type that has not been configured, a
26
+ # ConfigNotDefinedError exception will be raised.
27
+ #
28
+ class AdequateRateLimiter
29
+ # Lua script SHA1 digest
30
+ # @return [String]
31
+ attr_reader :sha1_digest
32
+
33
+ # Create a new AdequateRateLimter instance
34
+ # @param redis [Redis]
35
+ def initialize(redis)
36
+ load_script(redis)
37
+ end
38
+
39
+ # Configure rate limiting for an event type
40
+ # @param redis [Redis]
41
+ # @param event_type [String]
42
+ # @param max_allowed [Integer] Maximum allowed events for an actor
43
+ # @param over_interval [Integer] Over a rolling window of seconds
44
+ # @param lockout_interval [Integer] Seconds to lock out an actor from an event.
45
+ # @return [void]
46
+ # See README for more details.
47
+ def configure(redis, event_type, max_allowed, over_interval, lockout_interval)
48
+ key = namespaced_key(event_type)
49
+ redis.del(key)
50
+ redis.rpush(key, max_allowed)
51
+ redis.rpush(key, over_interval)
52
+ redis.rpush(key, lockout_interval)
53
+ end
54
+
55
+ # Check if an actor is allowed to perform an action of event_type.
56
+ # @param redis [Redis]
57
+ # @param event_type [String]
58
+ # @param actor [String]
59
+ # @return [Boolean]
60
+ def allow?(redis, event_type, actor)
61
+ q = available_quota(redis, event_type, actor)
62
+ q.positive?
63
+ end
64
+
65
+ def peek(redis, event_type, actor)
66
+ redis.lrange(namespaced_key("#{event_type}:#{actor}"), 0, -1)
67
+ end
68
+
69
+ def peek_config(redis, event_type)
70
+ redis.lrange(namespaced_key(event_type), 0, -1)
71
+ end
72
+
73
+ def namespaced_key(key)
74
+ "arl:#{key}"
75
+ end
76
+
77
+ class ConfigNotDefinedError < StandardError
78
+ end
79
+
80
+ private
81
+
82
+ # Fetch remaining quota, as a fraction of max_allowed for an event_type, actor pair.
83
+ # @param redis [Redis]
84
+ # @param event_type [String]
85
+ # @param actor [String]
86
+ # @return [Float]
87
+ def available_quota(redis, event_type, actor)
88
+ keys = [namespaced_key(event_type), actor]
89
+ argv = [Time.now.to_i, 1]
90
+
91
+ available_quota = 1.0
92
+
93
+ available_quota = redis.evalsha(sha1_digest, keys, argv).to_f
94
+ rescue Redis::CommandError => e
95
+ if e.to_s.include?('NOSCRIPT')
96
+ load_script(redis)
97
+ available_quota = redis.evalsha(sha1_digest, keys, argv).to_f
98
+ elsif e.to_s.include?('No config found')
99
+ raise ConfigNotDefinedError, e.to_s
100
+ end
101
+ ensure
102
+ available_quota
103
+ end
104
+
105
+ def load_script(redis)
106
+ lua_code = <<-LUA
107
+ local config_identifier = KEYS[1]
108
+ local actor_identifier = KEYS[2]
109
+
110
+ local config = redis.call('lrange', config_identifier, 0, -1)
111
+ if not next(config) then
112
+ return redis.error_reply("No config found for event type - "..config_identifier)
113
+ end
114
+
115
+ local max_allowed = tonumber(config[1])
116
+ local over_interval = tonumber(config[2])
117
+ local lockout_interval = tonumber(config[3])
118
+ local expire_in = over_interval + lockout_interval
119
+
120
+ local t1 = tonumber(ARGV[1])
121
+ local bump_counter = 0
122
+ if nil ~= ARGV[2] then
123
+ bump_counter = tonumber(ARGV[2])
124
+ end
125
+
126
+ local y = nil
127
+
128
+ local key = config_identifier..":"..actor_identifier
129
+ local tuple = redis.call('lrange', key, 0, -1)
130
+ -- Tuple format = {last_updated_score, last_updated_timestamp, last_blocked_timestamp}
131
+
132
+ if not next(tuple) then
133
+ y = bump_counter
134
+ if bump_counter > 0 then
135
+ -- Update tuple only if an event has occurred.
136
+ redis.call('rpush', key, y)
137
+ redis.call('rpush', key, t1)
138
+ redis.call('rpush', key, 0)
139
+ redis.call('expire', key, expire_in)
140
+ end
141
+ else
142
+ y = tonumber(tuple[1])
143
+ local t0 = tonumber(tuple[2])
144
+ local b = tonumber(tuple[3])
145
+
146
+ if t1 - b > lockout_interval then
147
+ -- If not in the lockout interval since the last block
148
+ -- Decay the old score (at t0) using the configured slope to compute the current value.
149
+ y = y - (max_allowed / over_interval) * (t1 - t0)
150
+ y = math.max(y, 0) -- Score cannot drop below 0.
151
+
152
+ if bump_counter > 0 then
153
+ -- Update tuple only if an event has occurred.
154
+ y = y + bump_counter
155
+
156
+ if y >= max_allowed then
157
+ y = max_allowed -- Score cannot go above max_allowed.
158
+ -- Set t1 as the last_blocked_timestamp
159
+ redis.call('lset', key, 2, t1)
160
+ end
161
+
162
+ redis.call('lset', key, 0, string.format("%.4f", y))
163
+ redis.call('lset', key, 1, t1)
164
+ redis.call('expire', key, expire_in)
165
+ end
166
+ end
167
+ end
168
+
169
+ return tostring(1.0 - y / max_allowed)
170
+ LUA
171
+
172
+ @sha1_digest = redis.script(:load, lua_code.freeze).freeze
173
+ end
174
+ end
175
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redis-adequate_rate_limiter
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Bhal Agashe
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-08-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ description: |
28
+ Uses a Lua script for Redis to provide smooth, configurable, space-efficient &
29
+ blazing fast rate limiting. The script is very lightweight and performs the entire operation
30
+ atomically. So it can be used to rate limit access to any resource at scale. Linked homepage
31
+ has more details.
32
+ email: ''
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - lib/redis/adequate_rate_limiter.rb
38
+ homepage: https://github.com/bagashe/redis-adequate-rate-limiter-rb
39
+ licenses:
40
+ - MIT
41
+ metadata: {}
42
+ post_install_message:
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '2.5'
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 3.2.5
58
+ signing_key:
59
+ specification_version: 4
60
+ summary: Provides rate limiting using Redis & a Lua script.
61
+ test_files: []