simple_throttle 1.1.0 → 1.1.2
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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +24 -2
- data/VERSION +1 -1
- data/lib/simple_throttle.rb +81 -39
- data/simple_throttle.gemspec +8 -4
- metadata +10 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfa3230d5cc40054d4745124ca652988cda2d3443d46693cf4de2611e1c6eac1
|
|
4
|
+
data.tar.gz: c75e2af15e6a114f26f705e48874e4b70bb05efbd2f68d9c52f0236d8c9e9ca2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c66cd0cac05fa9edf641c9b63acd664b0631421f8f31cd3937d2f2d67f7dc13cfe6764b30a3feb6963b50b4795594d3109c58de757c8b866dd82cc1a9627383c
|
|
7
|
+
data.tar.gz: 2312af9ccb5e69534195098109d3965fa6e1b92ea9e4bd8f92e591fecaa85359a6ad5ff1e6fbc3a56cc6c8d2c6479701b55a3f3e5c76d3801ce060cf746d76a2
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 1.1.2
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Fixed `wait_time` returning negative values when the throttle list held more than `limit` entries (possible after `increment!` or with `pause_to_recover`); it now reads the entry that actually frees a slot, handles a concurrently expired key, and never returns a value below zero.
|
|
12
|
+
- Fixed the throttle timestamps to use the Redis server clock instead of the calling process clock so that entries are ordered consistently and cleanup works correctly when multiple clients with skewed clocks share a throttle. `peek` and `wait_time` also read the Redis server clock so clock skew cannot cause miscounts or wait times longer than the ttl; `wait_time` is additionally capped at the ttl.
|
|
13
|
+
- Fixed `SimpleThrottle.new` to always store the name as a frozen `String` regardless of the argument type.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Throttle time is now measured entirely by the Redis server clock. Manipulating time in the calling process (i.e. with Timecop or by stubbing `Time.now`) no longer has any effect on throttles; tests need to use a short `ttl` and actually sleep. Reading the server clock does not add a round trip; `peek` and `wait_time` pipeline it with the read of the timestamp list.
|
|
18
|
+
- `increment!` now raises `ArgumentError` when given a non-positive amount instead of corrupting the count.
|
|
19
|
+
- Hardened thread safety of the global throttle registry and lazily-initialized Redis client.
|
|
20
|
+
|
|
21
|
+
## 1.1.1
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed `increment!` method to return the correct value after removing expired requests rather than the raw count from Redis.
|
|
26
|
+
|
|
7
27
|
## 1.1.0
|
|
8
28
|
|
|
9
29
|
### Added
|
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Simple Throttle
|
|
2
2
|
|
|
3
3
|
[](https://github.com/bdurand/simple_throttle/actions/workflows/continuous_integration.yml)
|
|
4
|
-
[](https://github.com/bdurand/simple_throttle/actions/workflows/regression_test.yml)
|
|
5
4
|
[](https://github.com/testdouble/standard)
|
|
5
|
+
[](https://badge.fury.io/rb/simple_throttle)
|
|
6
6
|
|
|
7
7
|
This gem provides a very simple throttling mechanism backed by Redis for limiting access to a resource. The throttle can be thought of as a limit on the number of calls in a set time frame (i.e. 100 calls per hour).
|
|
8
8
|
|
|
@@ -78,7 +78,29 @@ end
|
|
|
78
78
|
|
|
79
79
|
### Redis requirement
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
The `redis` gem version 4.5 or greater is required. Versions 4.5, 5.x, and 6.x are all supported and tested.
|
|
82
|
+
|
|
83
|
+
Redis server 3.2 or greater is required. The Lua script that maintains the throttle reads the server clock and then writes to the throttle list, which is only allowed under effects replication.
|
|
84
|
+
|
|
85
|
+
### Time is measured by the Redis server clock
|
|
86
|
+
|
|
87
|
+
All throttle timestamps come from the Redis server's clock rather than the clock of the process calling the throttle. This is deliberate: a throttle is usually shared by many processes, and if each one stamped entries with its own clock, then clock skew between them would corrupt the ordering of the list and cause entries to expire too early or too late. Using a single clock means every client agrees on the time window no matter how skewed their local clocks are.
|
|
88
|
+
|
|
89
|
+
There are two consequences worth knowing about:
|
|
90
|
+
|
|
91
|
+
- **Manipulating time in tests has no effect on throttles.** Tools like [Timecop](https://github.com/travisjeffery/timecop), `ActiveSupport::Testing::TimeHelpers`, or stubbing `Time.now` only change the clock inside your Ruby process. The Redis server is a separate process and keeps reporting the real time, so freezing or traveling through time will not expire throttle entries or change what `wait_time` returns. To test throttle expiration, create the throttle with a very short `ttl` and actually `sleep`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
throttle = SimpleThrottle.new("test", limit: 1, ttl: 0.1)
|
|
95
|
+
expect(throttle.allowed!).to eq true
|
|
96
|
+
expect(throttle.allowed!).to eq false
|
|
97
|
+
sleep(0.15)
|
|
98
|
+
expect(throttle.allowed!).to eq true
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use `reset!` to clear a throttle between tests.
|
|
102
|
+
|
|
103
|
+
- **Reading the server clock does not cost an extra round trip.** `allowed!` and `increment!` read the clock inside the Lua script they already run, and `peek` and `wait_time` pipeline the clock read together with the read of the timestamp list. Every operation is still a single round trip to Redis.
|
|
82
104
|
|
|
83
105
|
## Installation
|
|
84
106
|
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.1.
|
|
1
|
+
1.1.2
|
data/lib/simple_throttle.rb
CHANGED
|
@@ -11,16 +11,23 @@ class SimpleThrottle
|
|
|
11
11
|
# then the current entry will be added. The list is marked to expire with the oldest entry so
|
|
12
12
|
# there's no need to cleanup the lists.
|
|
13
13
|
LUA_SCRIPT = <<~LUA
|
|
14
|
+
redis.replicate_commands()
|
|
15
|
+
|
|
14
16
|
local list_key = KEYS[1]
|
|
15
17
|
local limit = tonumber(ARGV[1])
|
|
16
18
|
local ttl = tonumber(ARGV[2])
|
|
17
|
-
local
|
|
18
|
-
local
|
|
19
|
-
local
|
|
19
|
+
local pause_to_recover = tonumber(ARGV[3])
|
|
20
|
+
local amount = tonumber(ARGV[4])
|
|
21
|
+
local cleanup = tonumber(ARGV[5])
|
|
22
|
+
|
|
23
|
+
-- Use the Redis server clock so timestamps are consistent and monotonic
|
|
24
|
+
-- across all clients regardless of individual machine clock skew.
|
|
25
|
+
local time = redis.call('time')
|
|
26
|
+
local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
|
|
20
27
|
|
|
21
28
|
local size = redis.call('llen', list_key)
|
|
22
|
-
if size >= limit then
|
|
23
|
-
local expired =
|
|
29
|
+
if size >= limit or (cleanup > 0 and size > 0) then
|
|
30
|
+
local expired = now - ttl
|
|
24
31
|
while size > 0 do
|
|
25
32
|
local t = redis.call('lpop', list_key)
|
|
26
33
|
if tonumber(t) > expired then
|
|
@@ -50,6 +57,9 @@ class SimpleThrottle
|
|
|
50
57
|
LUA
|
|
51
58
|
|
|
52
59
|
@lock = Mutex.new
|
|
60
|
+
@redis_client = nil
|
|
61
|
+
@script_sha_1 = nil
|
|
62
|
+
@throttles = {}.freeze
|
|
53
63
|
|
|
54
64
|
class << self
|
|
55
65
|
# Add a global throttle that can be referenced later with the [] method.
|
|
@@ -62,12 +72,15 @@ class SimpleThrottle
|
|
|
62
72
|
# @param pause_to_recover [Boolean] require processes calling the throttle
|
|
63
73
|
# to pause at least temporarily before freeing up the throttle. If this is true,
|
|
64
74
|
# then a throttle called constantly with no pauses will never free up.
|
|
65
|
-
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a
|
|
75
|
+
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redis instance
|
|
66
76
|
# @return [void]
|
|
67
77
|
def add(name, ttl:, limit:, pause_to_recover: false, redis: nil)
|
|
68
78
|
@lock.synchronize do
|
|
69
|
-
|
|
70
|
-
|
|
79
|
+
# Copy-on-write so that lock-free readers in `[]` always see a
|
|
80
|
+
# fully-populated, immutable hash and never a partially rehashed one.
|
|
81
|
+
throttles = @throttles.dup
|
|
82
|
+
throttles[name.to_s] = new(name, limit: limit, ttl: ttl, pause_to_recover: pause_to_recover, redis: redis)
|
|
83
|
+
@throttles = throttles.freeze
|
|
71
84
|
end
|
|
72
85
|
end
|
|
73
86
|
|
|
@@ -76,9 +89,9 @@ class SimpleThrottle
|
|
|
76
89
|
# @param name [String, Symbol] name of the throttle
|
|
77
90
|
# @return [SimpleThrottle]
|
|
78
91
|
def [](name)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
# Assign then read for thread safety.
|
|
93
|
+
throttles = @throttles
|
|
94
|
+
throttles[name.to_s] if throttles
|
|
82
95
|
end
|
|
83
96
|
|
|
84
97
|
# Set the Redis instance to use for maintaining the throttle. This can either be set
|
|
@@ -98,11 +111,12 @@ class SimpleThrottle
|
|
|
98
111
|
#
|
|
99
112
|
# @return [Redis]
|
|
100
113
|
def redis
|
|
101
|
-
@redis_client ||= Redis.new
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
@lock.synchronize { @redis_client ||= Redis.new } unless @redis_client
|
|
115
|
+
client = @redis_client
|
|
116
|
+
if client.is_a?(Proc)
|
|
117
|
+
client.call
|
|
104
118
|
else
|
|
105
|
-
|
|
119
|
+
client
|
|
106
120
|
end
|
|
107
121
|
end
|
|
108
122
|
|
|
@@ -110,14 +124,15 @@ class SimpleThrottle
|
|
|
110
124
|
|
|
111
125
|
def execute_lua_script(redis:, keys:, args:)
|
|
112
126
|
client = redis
|
|
113
|
-
@script_sha_1
|
|
127
|
+
sha1 = @script_sha_1
|
|
128
|
+
sha1 ||= @lock.synchronize { @script_sha_1 ||= client.script(:load, LUA_SCRIPT) }
|
|
114
129
|
attempts = 0
|
|
115
130
|
|
|
116
131
|
begin
|
|
117
|
-
client.evalsha(
|
|
132
|
+
client.evalsha(sha1, Array(keys), Array(args))
|
|
118
133
|
rescue Redis::CommandError => e
|
|
119
134
|
if e.message.include?("NOSCRIPT") && attempts < 2
|
|
120
|
-
@script_sha_1 = client.script(:load, LUA_SCRIPT)
|
|
135
|
+
sha1 = @lock.synchronize { @script_sha_1 = client.script(:load, LUA_SCRIPT) }
|
|
121
136
|
attempts += 1
|
|
122
137
|
retry
|
|
123
138
|
else
|
|
@@ -137,10 +152,9 @@ class SimpleThrottle
|
|
|
137
152
|
# @param pause_to_recover [Boolean] require processes calling the throttle
|
|
138
153
|
# to pause at least temporarily before freeing up the throttle. If this is true,
|
|
139
154
|
# then a throttle called constantly with no pauses will never free up.
|
|
140
|
-
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a
|
|
155
|
+
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redis instance
|
|
141
156
|
def initialize(name, ttl:, limit:, pause_to_recover: false, redis: nil)
|
|
142
|
-
@name = name.to_s
|
|
143
|
-
@name = name.dup.freeze unless name.frozen?
|
|
157
|
+
@name = name.to_s.dup.freeze
|
|
144
158
|
@limit = limit.to_i
|
|
145
159
|
@ttl = ttl.to_f
|
|
146
160
|
@pause_to_recover = !!pause_to_recover
|
|
@@ -152,7 +166,7 @@ class SimpleThrottle
|
|
|
152
166
|
#
|
|
153
167
|
# @return [Boolean]
|
|
154
168
|
def allowed!
|
|
155
|
-
size =
|
|
169
|
+
size = add_request(1, false)
|
|
156
170
|
size <= limit
|
|
157
171
|
end
|
|
158
172
|
|
|
@@ -160,18 +174,13 @@ class SimpleThrottle
|
|
|
160
174
|
# how the throttle is implemented in Redis, the return value will always max
|
|
161
175
|
# out at the throttle limit + 1 or, if the pause to recover option is set, limit + 2.
|
|
162
176
|
#
|
|
163
|
-
# @param amount [Integer] amount to increment the throttle by
|
|
177
|
+
# @param amount [Integer] amount to increment the throttle by (must be positive)
|
|
164
178
|
# @return [Integer]
|
|
165
179
|
def increment!(amount = 1)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
:execute_lua_script,
|
|
171
|
-
redis: redis_client,
|
|
172
|
-
keys: [redis_key],
|
|
173
|
-
args: [limit, ttl_ms, time_ms, pause_to_recover_arg, amount]
|
|
174
|
-
)
|
|
180
|
+
amount = amount.to_i
|
|
181
|
+
raise ArgumentError, "amount must be a positive integer" if amount < 1
|
|
182
|
+
|
|
183
|
+
add_request(amount, true)
|
|
175
184
|
end
|
|
176
185
|
|
|
177
186
|
# Reset a throttle back to zero.
|
|
@@ -185,8 +194,8 @@ class SimpleThrottle
|
|
|
185
194
|
#
|
|
186
195
|
# @return [Integer]
|
|
187
196
|
def peek
|
|
188
|
-
timestamps =
|
|
189
|
-
min_timestamp = ((
|
|
197
|
+
timestamps, now = timestamps_with_server_time
|
|
198
|
+
min_timestamp = ((now - ttl) * 1000).ceil
|
|
190
199
|
timestamps.count { |t| t > min_timestamp }
|
|
191
200
|
end
|
|
192
201
|
|
|
@@ -196,13 +205,19 @@ class SimpleThrottle
|
|
|
196
205
|
#
|
|
197
206
|
# @return [Float]
|
|
198
207
|
def wait_time
|
|
199
|
-
|
|
208
|
+
timestamps, now = timestamps_with_server_time
|
|
209
|
+
min_timestamp = ((now - ttl) * 1000).ceil
|
|
210
|
+
if timestamps.count { |t| t > min_timestamp } < limit
|
|
200
211
|
0.0
|
|
201
212
|
else
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
# The entry that frees up a slot is the limit-th newest (index -limit),
|
|
214
|
+
# not the head of the list, since the list can legitimately hold more
|
|
215
|
+
# than `limit` entries (increment! and pause_to_recover both add extras).
|
|
216
|
+
oldest = timestamps[-limit]
|
|
217
|
+
return 0.0 if oldest.nil?
|
|
218
|
+
first = oldest.to_f / 1000.0
|
|
219
|
+
wait = ttl - (now - first)
|
|
220
|
+
wait.clamp(0.0, ttl)
|
|
206
221
|
end
|
|
207
222
|
end
|
|
208
223
|
|
|
@@ -219,4 +234,31 @@ class SimpleThrottle
|
|
|
219
234
|
def redis_key
|
|
220
235
|
"simple_throttle.#{name}"
|
|
221
236
|
end
|
|
237
|
+
|
|
238
|
+
# The Lua script stores timestamps from the Redis server clock, so reads
|
|
239
|
+
# must be measured against that same clock rather than the local one. Both
|
|
240
|
+
# values are fetched in a single pipeline so that reading the clock doesn't
|
|
241
|
+
# cost an extra round trip.
|
|
242
|
+
#
|
|
243
|
+
# @return [Array(Array<Integer>, Float)] the tracked timestamps in
|
|
244
|
+
# milliseconds and the current Redis server time in seconds.
|
|
245
|
+
def timestamps_with_server_time
|
|
246
|
+
timestamps, time = redis_client.pipelined do |pipeline|
|
|
247
|
+
pipeline.lrange(redis_key, 0, -1)
|
|
248
|
+
pipeline.time
|
|
249
|
+
end
|
|
250
|
+
seconds, microseconds = time
|
|
251
|
+
[timestamps.collect(&:to_i), seconds.to_i + (microseconds.to_i / 1_000_000.0)]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def add_request(amount, cleanup)
|
|
255
|
+
pause_to_recover_arg = (@pause_to_recover ? 1 : 0)
|
|
256
|
+
ttl_ms = (ttl * 1000).ceil
|
|
257
|
+
self.class.send(
|
|
258
|
+
:execute_lua_script,
|
|
259
|
+
redis: redis_client,
|
|
260
|
+
keys: [redis_key],
|
|
261
|
+
args: [limit, ttl_ms, pause_to_recover_arg, amount, (cleanup ? 1 : 0)]
|
|
262
|
+
)
|
|
263
|
+
end
|
|
222
264
|
end
|
data/simple_throttle.gemspec
CHANGED
|
@@ -8,6 +8,12 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.homepage = "https://github.com/bdurand/simple_throttle"
|
|
9
9
|
spec.license = "MIT"
|
|
10
10
|
|
|
11
|
+
spec.metadata = {
|
|
12
|
+
"homepage_uri" => spec.homepage,
|
|
13
|
+
"source_code_uri" => spec.homepage,
|
|
14
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
# Specify which files should be added to the gem when it is released.
|
|
12
18
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
13
19
|
ignore_files = %w[
|
|
@@ -26,9 +32,7 @@ Gem::Specification.new do |spec|
|
|
|
26
32
|
|
|
27
33
|
spec.require_paths = ["lib"]
|
|
28
34
|
|
|
29
|
-
spec.add_dependency "redis"
|
|
30
|
-
|
|
31
|
-
spec.add_development_dependency "bundler"
|
|
35
|
+
spec.add_dependency "redis", ">= 4.5"
|
|
32
36
|
|
|
33
|
-
spec.required_ruby_version = ">= 2.
|
|
37
|
+
spec.required_ruby_version = ">= 2.6"
|
|
34
38
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: simple_throttle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: redis
|
|
@@ -16,29 +15,14 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
18
|
+
version: '4.5'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: bundler
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - ">="
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0'
|
|
34
|
-
type: :development
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - ">="
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0'
|
|
41
|
-
description:
|
|
25
|
+
version: '4.5'
|
|
42
26
|
email:
|
|
43
27
|
- bbdurand@gmail.com
|
|
44
28
|
executables: []
|
|
@@ -54,8 +38,10 @@ files:
|
|
|
54
38
|
homepage: https://github.com/bdurand/simple_throttle
|
|
55
39
|
licenses:
|
|
56
40
|
- MIT
|
|
57
|
-
metadata:
|
|
58
|
-
|
|
41
|
+
metadata:
|
|
42
|
+
homepage_uri: https://github.com/bdurand/simple_throttle
|
|
43
|
+
source_code_uri: https://github.com/bdurand/simple_throttle
|
|
44
|
+
changelog_uri: https://github.com/bdurand/simple_throttle/blob/main/CHANGELOG.md
|
|
59
45
|
rdoc_options: []
|
|
60
46
|
require_paths:
|
|
61
47
|
- lib
|
|
@@ -63,15 +49,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
63
49
|
requirements:
|
|
64
50
|
- - ">="
|
|
65
51
|
- !ruby/object:Gem::Version
|
|
66
|
-
version: '2.
|
|
52
|
+
version: '2.6'
|
|
67
53
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
54
|
requirements:
|
|
69
55
|
- - ">="
|
|
70
56
|
- !ruby/object:Gem::Version
|
|
71
57
|
version: '0'
|
|
72
58
|
requirements: []
|
|
73
|
-
rubygems_version:
|
|
74
|
-
signing_key:
|
|
59
|
+
rubygems_version: 4.0.3
|
|
75
60
|
specification_version: 4
|
|
76
61
|
summary: Simple redis backed throttling mechanism to limit access to a resource
|
|
77
62
|
test_files: []
|