prorate 0.6.0 → 0.7.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 +5 -5
- data/CHANGELOG.md +39 -0
- data/README.md +33 -2
- data/Rakefile +1 -1
- data/lib/prorate.rb +0 -2
- data/lib/prorate/leaky_bucket.lua +77 -0
- data/lib/prorate/leaky_bucket.rb +134 -0
- data/lib/prorate/rate_limit.lua +3 -1
- data/lib/prorate/throttle.rb +68 -41
- data/lib/prorate/version.rb +1 -1
- data/prorate.gemspec +1 -2
- metadata +8 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: bb2c78403fd3d37fd073ccf736618673e532c6fc53efd7e1c342c7edebc4037f
|
4
|
+
data.tar.gz: 497b74b1d07d1590e44f338f7150b6b75fe5ed154af82d052381915a2b174c69
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d2a262971d745073dfd385088d92bf40667fa8108e6c3b71982b17fad41d6ee94472e40f8189badfa6131ac853a0dfe381e9bfbef93a0b7bbd24c3f39339251f
|
7
|
+
data.tar.gz: 33f4f60558e7cee9fd671ebe48f4e35fc67e58c2ea5ad5cde5e40368b8b486e941eabb71ddf3dfaa87a9380689d31c059d56ea81cfa8860a955409d075840824
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# 0.7.0
|
2
|
+
|
3
|
+
* Add a naked `LeakyBucket` object which allows one to build sophisticated rate limiting relying
|
4
|
+
on the Ruby side of things more. It has less features than the `Throttle` but can be used for more
|
5
|
+
fine-graned control of the throttling. It also does not use exceptions for flow control.
|
6
|
+
The `Throttle` object used them because it should make the code abort *loudly* if a throttle is hit, but
|
7
|
+
when the objective is to measure instead a smaller, less opinionated module can be more useful.
|
8
|
+
* Refactor the internals of the Throttle class so that it uses a default Logger, and document the arguments.
|
9
|
+
* Use fractional time measurement from Redis in Lua code. For our throttle to be precise we cannot really
|
10
|
+
limit ourselves to "anchored slots" on the start of a second, and we would be effectively doing that
|
11
|
+
with our previous setup.
|
12
|
+
* Fix the `redis` gem deprecation warnings when using `exists` - we will now use `exists?` if available.
|
13
|
+
* Remove dependency on the `ks` gem as we can use vanilla Structs or classes instead.
|
14
|
+
|
15
|
+
# 0.6.0
|
16
|
+
|
17
|
+
* Add `Throttle#status` method for retrieving the status of a throttle without placing any tokens
|
18
|
+
or raising any exceptions. This is useful for layered throttles.
|
19
|
+
|
20
|
+
# 0.5.0
|
21
|
+
|
22
|
+
* Allow setting the number of tokens to add to the bucket in `Throttle#throttle!` - this is useful because
|
23
|
+
sometimes a request effectively uses N of some resource in one go, and should thus cause a throttle
|
24
|
+
to fire without having to do repeated calls
|
25
|
+
|
26
|
+
# 0.4.0
|
27
|
+
|
28
|
+
* When raising a `Throttled` exception, add the name of the throttle to it. This is useful when multiple
|
29
|
+
throttles are used together and one needs to find out which throttle has fired.
|
30
|
+
* Reformat code according to wetransfer_style and make it compulsory on CI
|
31
|
+
|
32
|
+
# 0.3.0
|
33
|
+
|
34
|
+
* Replace the Ruby implementation of the throttle with a Lua script which runs within Redis. This allows us
|
35
|
+
to do atomic gets+sets very rapidly.
|
36
|
+
|
37
|
+
# 0.1.0
|
38
|
+
|
39
|
+
* Initial release of Prorate
|
data/README.md
CHANGED
@@ -61,11 +61,11 @@ rescue_from Prorate::Throttled do |e|
|
|
61
61
|
end
|
62
62
|
```
|
63
63
|
|
64
|
-
### Throttling and checking
|
64
|
+
### Throttling and checking status
|
65
65
|
|
66
66
|
More exquisite control can be achieved by combining throttling (see previous
|
67
67
|
step) and - in subsequent calls - checking the status of the throttle before
|
68
|
-
invoking the throttle.
|
68
|
+
invoking the throttle. **When you call `throttle!`, you add tokens to the leaky bucket.**
|
69
69
|
|
70
70
|
Let's say you have an endpoint that not only needs throttling, but you want to
|
71
71
|
ban [credential stuffers](https://en.wikipedia.org/wiki/Credential_stuffing)
|
@@ -120,6 +120,37 @@ rescue_from Prorate::Throttled do |e|
|
|
120
120
|
end
|
121
121
|
```
|
122
122
|
|
123
|
+
## Using just the leaky bucket
|
124
|
+
|
125
|
+
There is also an object for using the heart of Prorate (the leaky bucket) without blocking or exceptions. This is useful
|
126
|
+
if you want to implement a more generic rate limiting solution and customise it in a fancier way. The leaky bucket on
|
127
|
+
it's own provides the following conveniences only:
|
128
|
+
|
129
|
+
* Track the number of tokens added and the number of tokens that have leaked
|
130
|
+
* Tracks whether a specific token fillup has overflown the bucket. This is only tracked momentarily if the bucket is limited
|
131
|
+
|
132
|
+
Level and leak rate are computed and provided as Floats instead of Integers (in the Throttle class).
|
133
|
+
To use it, employ the `LeakyBucket` object:
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
# The leak_rate is in tokens per second
|
137
|
+
leaky_bucket = Prorate::LeakyBucket.new(redis: Redis.new, redis_key_prefix: "user123", leak_rate: 0.8, bucket_capacity: 2)
|
138
|
+
leaky_bucket.state.level #=> will return 0.0
|
139
|
+
leaky_bucket.state.full? #=> will return "false"
|
140
|
+
state_after_add = leaky_bucket.fillup(2) #=> returns a State object_
|
141
|
+
state_after_add.full? #=> will return "true"
|
142
|
+
state_after_add.level #=> will return 2.0
|
143
|
+
```
|
144
|
+
|
145
|
+
## Why Lua?
|
146
|
+
|
147
|
+
Prorate is implementing throttling using the "Leaky Bucket" algorithm and is extensively described [here](https://github.com/WeTransfer/prorate/blob/master/lib/prorate/throttle.rb). The implementation is using a Lua script, because is the only language available which runs _inside_ Redis. Thanks to the speed benefits of Lua the script runs fast enough to apply it on every throttle call.
|
148
|
+
|
149
|
+
Using a Lua script in Prorate helps us achieve the following guarantees:
|
150
|
+
|
151
|
+
- **The script will run atomically.** The script is evaluated as a single Redis command. This ensures that the commands in the Lua script will never be interleaved with another client: they will always execute together.
|
152
|
+
- **Any usages of time will use the Redis time.** Throttling requires a consistent and monotonic _time source_. The only monotonic and consistent time source which is usable in the context of Prorate, is the `TIME` result of Redis itself. We are throttling requests from different machines, which will invariably have clock drift between them. This way using the Redis server `TIME` helps achieve consistency.
|
153
|
+
|
123
154
|
## Development
|
124
155
|
|
125
156
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/Rakefile
CHANGED
@@ -6,7 +6,7 @@ require 'yard'
|
|
6
6
|
YARD::Rake::YardocTask.new(:doc) do |t|
|
7
7
|
# The dash has to be between the two to "divide" the source files and
|
8
8
|
# miscellaneous documentation files that contain no code
|
9
|
-
t.files = ['lib/**/*.rb', '-', 'LICENSE.txt']
|
9
|
+
t.files = ['lib/**/*.rb', '-', 'LICENSE.txt', 'CHANGELOG.md']
|
10
10
|
end
|
11
11
|
|
12
12
|
RSpec::Core::RakeTask.new(:spec)
|
data/lib/prorate.rb
CHANGED
@@ -0,0 +1,77 @@
|
|
1
|
+
-- Single threaded Leaky Bucket implementation (without blocking).
|
2
|
+
-- args: key_base, leak_rate, bucket_ttl, fillup. To just verify the state of the bucket leak_rate of 0 may be passed.
|
3
|
+
-- returns: the leve of the bucket in number of tokens
|
4
|
+
|
5
|
+
-- this is required to be able to use TIME and writes; basically it lifts the script into IO
|
6
|
+
redis.replicate_commands()
|
7
|
+
|
8
|
+
-- Redis documentation recommends passing the keys separately so that Redis
|
9
|
+
-- can - in the future - verify that they live on the same shard of a cluster, and
|
10
|
+
-- raise an error if they are not. As far as can be understood this functionality is not
|
11
|
+
-- yet present, but if we can make a little effort to make ourselves more future proof
|
12
|
+
-- we should.
|
13
|
+
local bucket_level_key = KEYS[1]
|
14
|
+
local last_updated_key = KEYS[2]
|
15
|
+
|
16
|
+
local leak_rate = tonumber(ARGV[1])
|
17
|
+
local fillup = tonumber(ARGV[2]) -- How many tokens this call adds to the bucket.
|
18
|
+
local bucket_capacity = tonumber(ARGV[3]) -- How many tokens is the bucket allowed to contain
|
19
|
+
|
20
|
+
-- Compute the key TTL for the bucket. We are interested in how long it takes the bucket
|
21
|
+
-- to leak all the way to 0, as this is the time when the values stay relevant. We pad with 1 second
|
22
|
+
-- to have a little cushion.
|
23
|
+
local key_lifetime = math.ceil((bucket_capacity / leak_rate) + 1)
|
24
|
+
|
25
|
+
-- Take a timestamp
|
26
|
+
local redis_time = redis.call("TIME") -- Array of [seconds, microseconds]
|
27
|
+
local now = tonumber(redis_time[1]) + (tonumber(redis_time[2]) / 1000000)
|
28
|
+
|
29
|
+
-- get current bucket level. The throttle key might not exist yet in which
|
30
|
+
-- case we default to 0
|
31
|
+
local bucket_level = tonumber(redis.call("GET", bucket_level_key)) or 0
|
32
|
+
|
33
|
+
-- ...and then perform the leaky bucket fillup/leak. We need to do this also when the bucket has
|
34
|
+
-- just been created because the initial fillup to add might be so high that it will
|
35
|
+
-- immediately overflow the bucket and trigger the throttle, on the first call.
|
36
|
+
local last_updated = tonumber(redis.call("GET", last_updated_key)) or now -- use sensible default of 'now' if the key does not exist
|
37
|
+
|
38
|
+
-- Subtract the number of tokens leaked since last call
|
39
|
+
local dt = now - last_updated
|
40
|
+
local new_bucket_level = bucket_level - (leak_rate * dt) + fillup
|
41
|
+
|
42
|
+
-- and _then_ and add the tokens we fillup with. Cap the value to be 0 < capacity
|
43
|
+
new_bucket_level = math.max(0, math.min(bucket_capacity, new_bucket_level))
|
44
|
+
|
45
|
+
-- Since we return a floating point number string-formatted even if the bucket is full we
|
46
|
+
-- have some loss of precision in the formatting, even if the bucket was actually full.
|
47
|
+
-- This bit of information is useful to preserve.
|
48
|
+
local at_capacity = 0
|
49
|
+
if new_bucket_level == bucket_capacity then
|
50
|
+
at_capacity = 1
|
51
|
+
end
|
52
|
+
|
53
|
+
-- If both the initial level was 0, and the level after putting tokens in is 0 we
|
54
|
+
-- can avoid setting keys in Redis at all as this was only a level check.
|
55
|
+
if new_bucket_level == 0 and bucket_level == 0 then
|
56
|
+
return {"0.0", at_capacity}
|
57
|
+
end
|
58
|
+
|
59
|
+
-- Save the new bucket level
|
60
|
+
redis.call("SETEX", bucket_level_key, key_lifetime, new_bucket_level)
|
61
|
+
|
62
|
+
-- Record when we updated the bucket so that the amount of tokens leaked
|
63
|
+
-- can be correctly determined on the next invocation
|
64
|
+
redis.call("SETEX", last_updated_key, key_lifetime, now)
|
65
|
+
|
66
|
+
-- Most Redis adapters when used with the Lua interface truncate floats
|
67
|
+
-- to integers (at least in Python that is documented to be the case in
|
68
|
+
-- the Redis ebook here
|
69
|
+
-- https://redislabs.com/ebook/part-3-next-steps/chapter-11-scripting-redis-with-lua/11-1-adding-functionality-without-writing-c
|
70
|
+
-- We need access to the bucket level as a float value since our leak rate might as well be floating point, and to achieve that
|
71
|
+
-- we can go two ways. We can turn the float into a Lua string, and then parse it on the other side, or we can convert it to
|
72
|
+
-- a tuple of two integer values - one for the integer component and one for fraction.
|
73
|
+
-- Now, the unpleasant aspect is that when we do this we will lose precision - the number is not going to be
|
74
|
+
-- exactly equal to capacity, thus we lose the bit of information which tells us whether we filled up the bucket or not.
|
75
|
+
-- Also since the only moment we can register whether the bucket is above capacity is now - in this script, since
|
76
|
+
-- by the next call some tokens will have leaked.
|
77
|
+
return {string.format("%.9f", new_bucket_level), at_capacity}
|
@@ -0,0 +1,134 @@
|
|
1
|
+
module Prorate
|
2
|
+
|
3
|
+
# This offers just the leaky bucket implementation with fill control, but without the timed lock.
|
4
|
+
# It does not raise any exceptions, it just tracks the state of a leaky bucket in Redis.
|
5
|
+
#
|
6
|
+
# Important differences from the more full-featured Throttle class are:
|
7
|
+
#
|
8
|
+
# * No logging (as most meaningful code lives in Lua anyway)
|
9
|
+
# * No timed block - if you need to keep track of timed blocking it can be done externally
|
10
|
+
# * Leak rate is specified directly in tokens per second, instead of specifying the block period.
|
11
|
+
# * The bucket level is stored and returned as a Float which allows for finer-grained measurement,
|
12
|
+
# but more importantly - makes testing from the outside easier.
|
13
|
+
#
|
14
|
+
# It does have a few downsides compared to the Throttle though
|
15
|
+
#
|
16
|
+
# * Bucket is only full momentarily. On subsequent calls some tokens will leak already, so you either
|
17
|
+
# need to do delta checks on the value or rely on putting the token into the bucket.
|
18
|
+
class LeakyBucket
|
19
|
+
LUA_SCRIPT_CODE = File.read(File.join(__dir__, "leaky_bucket.lua"))
|
20
|
+
LUA_SCRIPT_HASH = Digest::SHA1.hexdigest(LUA_SCRIPT_CODE)
|
21
|
+
|
22
|
+
class BucketState < Struct.new(:level, :full)
|
23
|
+
# Returns the level of the bucket after the operation on the LeakyBucket
|
24
|
+
# object has taken place. There is a guarantee that no tokens have leaked
|
25
|
+
# from the bucket between the operation and the freezing of the BucketState
|
26
|
+
# struct.
|
27
|
+
#
|
28
|
+
# @!attribute [r] level
|
29
|
+
# @return [Float]
|
30
|
+
|
31
|
+
# Tells whether the bucket was detected to be full when the operation on
|
32
|
+
# the LeakyBucket was performed. There is a guarantee that no tokens have leaked
|
33
|
+
# from the bucket between the operation and the freezing of the BucketState
|
34
|
+
# struct.
|
35
|
+
#
|
36
|
+
# @!attribute [r] full
|
37
|
+
# @return [Boolean]
|
38
|
+
|
39
|
+
alias_method :full?, :full
|
40
|
+
|
41
|
+
# Returns the bucket level of the bucket state as a Float
|
42
|
+
#
|
43
|
+
# @return [Float]
|
44
|
+
def to_f
|
45
|
+
level.to_f
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns the bucket level of the bucket state rounded to an Integer
|
49
|
+
#
|
50
|
+
# @return [Integer]
|
51
|
+
def to_i
|
52
|
+
level.to_i
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Creates a new LeakyBucket. The object controls 2 keys in Redis: one
|
57
|
+
# for the last access time, and one for the contents of the key.
|
58
|
+
#
|
59
|
+
# @param redis_key_prefix[String] the prefix that is going to be used for keys.
|
60
|
+
# If your bucket is specific to a user, a browser or an IP address you need to mix in
|
61
|
+
# those values into the key prefix as appropriate.
|
62
|
+
# @param leak_rate[Float] the leak rate of the bucket, in tokens per second
|
63
|
+
# @param redis[Redis,#with] a Redis connection or a ConnectonPool instance
|
64
|
+
# if you are using the connection_pool gem. With a connection pool Prorate will
|
65
|
+
# checkout a connection using `#with` and check it in when it's done.
|
66
|
+
# @param bucket_capacity[Numeric] how many tokens is the bucket capped at.
|
67
|
+
# Filling up the bucket using `fillup()` will add to that number, but
|
68
|
+
# the bucket contents will then be capped at this value. So with
|
69
|
+
# bucket_capacity set to 12 and a `fillup(14)` the bucket will reach the level
|
70
|
+
# of 12, and will then immediately start leaking again.
|
71
|
+
def initialize(redis_key_prefix:, leak_rate:, redis:, bucket_capacity:)
|
72
|
+
@redis_key_prefix = redis_key_prefix
|
73
|
+
@redis = NullPool.new(redis) unless redis.respond_to?(:with)
|
74
|
+
@leak_rate = leak_rate.to_f
|
75
|
+
@capacity = bucket_capacity.to_f
|
76
|
+
end
|
77
|
+
|
78
|
+
# Places `n` tokens in the bucket.
|
79
|
+
#
|
80
|
+
# @return [BucketState] the state of the bucket after the operation
|
81
|
+
def fillup(n_tokens)
|
82
|
+
run_lua_bucket_script(n_tokens.to_f)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns the current state of the bucket, containing the level and whether the bucket is full
|
86
|
+
#
|
87
|
+
# @return [BucketState] the state of the bucket after the operation
|
88
|
+
def state
|
89
|
+
run_lua_bucket_script(0)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Returns the Redis key for the leaky bucket itself
|
93
|
+
# Note that the key is not guaranteed to contain a value if the bucket has not been filled
|
94
|
+
# up recently.
|
95
|
+
#
|
96
|
+
# @return [String]
|
97
|
+
def leaky_bucket_key
|
98
|
+
"#{@redis_key_prefix}.leaky_bucket.bucket_level"
|
99
|
+
end
|
100
|
+
|
101
|
+
# Returns the Redis key under which the last updated time of the bucket gets stored.
|
102
|
+
# Note that the key is not guaranteed to contain a value if the bucket has not been filled
|
103
|
+
# up recently.
|
104
|
+
#
|
105
|
+
# @return [String]
|
106
|
+
def last_updated_key
|
107
|
+
"#{@redis_key_prefix}.leaky_bucket.last_updated"
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def run_lua_bucket_script(n_tokens)
|
113
|
+
@redis.with do |r|
|
114
|
+
begin
|
115
|
+
# The script returns a tuple of "whole tokens, microtokens"
|
116
|
+
# to be able to smuggle the float across (similar to Redis TIME command)
|
117
|
+
level_str, is_full_int = r.evalsha(
|
118
|
+
LUA_SCRIPT_HASH,
|
119
|
+
keys: [leaky_bucket_key, last_updated_key], argv: [@leak_rate, n_tokens, @capacity])
|
120
|
+
BucketState.new(level_str.to_f, is_full_int == 1)
|
121
|
+
rescue Redis::CommandError => e
|
122
|
+
if e.message.include? "NOSCRIPT"
|
123
|
+
# The Redis server has never seen this script before. Needs to run only once in the entire lifetime
|
124
|
+
# of the Redis server, until the script changes - in which case it will be loaded under a different SHA
|
125
|
+
r.script(:load, LUA_SCRIPT_CODE)
|
126
|
+
retry
|
127
|
+
else
|
128
|
+
raise e
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
data/lib/prorate/rate_limit.lua
CHANGED
@@ -15,8 +15,10 @@ local max_bucket_capacity = tonumber(ARGV[2])
|
|
15
15
|
local leak_rate = tonumber(ARGV[3])
|
16
16
|
local block_duration = tonumber(ARGV[4])
|
17
17
|
local n_tokens = tonumber(ARGV[5]) -- How many tokens this call adds to the bucket. Defaults to 1
|
18
|
-
local now = tonumber(redis.call("TIME")[1]) --unix timestamp, will be required in all paths
|
19
18
|
|
19
|
+
-- Take the Redis timestamp
|
20
|
+
local redis_time = redis.call("TIME") -- Array of [seconds, microseconds]
|
21
|
+
local now = tonumber(redis_time[1]) + (tonumber(redis_time[2]) / 1000000)
|
20
22
|
local key_lifetime = math.ceil(max_bucket_capacity / leak_rate)
|
21
23
|
|
22
24
|
local blocked_until = redis.call("GET", block_key)
|
data/lib/prorate/throttle.rb
CHANGED
@@ -4,15 +4,27 @@ module Prorate
|
|
4
4
|
class MisconfiguredThrottle < StandardError
|
5
5
|
end
|
6
6
|
|
7
|
-
class Throttle
|
7
|
+
class Throttle
|
8
8
|
LUA_SCRIPT_CODE = File.read(File.join(__dir__, "rate_limit.lua"))
|
9
9
|
LUA_SCRIPT_HASH = Digest::SHA1.hexdigest(LUA_SCRIPT_CODE)
|
10
10
|
|
11
|
-
|
12
|
-
|
11
|
+
attr_reader :name, :limit, :period, :block_for, :redis, :logger
|
12
|
+
|
13
|
+
def initialize(name:, limit:, period:, block_for:, redis:, logger: Prorate::NullLogger)
|
14
|
+
@name = name.to_s
|
13
15
|
@discriminators = [name.to_s]
|
14
|
-
|
16
|
+
@redis = NullPool.new(redis) unless redis.respond_to?(:with)
|
17
|
+
@logger = logger
|
18
|
+
@block_for = block_for
|
19
|
+
|
15
20
|
raise MisconfiguredThrottle if (period <= 0) || (limit <= 0)
|
21
|
+
|
22
|
+
# Do not do type conversions here since we want to allow the caller to read
|
23
|
+
# those values back later
|
24
|
+
# (API contract which the previous implementation of Throttle already supported)
|
25
|
+
@limit = limit
|
26
|
+
@period = period
|
27
|
+
|
16
28
|
@leak_rate = limit.to_f / period # tokens per second;
|
17
29
|
end
|
18
30
|
|
@@ -75,56 +87,71 @@ module Prorate
|
|
75
87
|
# with a arbitrary ratio - like 1 token per inserted row. Once the bucket fills up
|
76
88
|
# the Throttled exception is going to be raised. Defaults to 1.
|
77
89
|
def throttle!(n_tokens: 1)
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
n_tokens: n_tokens)
|
90
|
-
|
91
|
-
if remaining_block_time > 0
|
92
|
-
logger.warn { "Throttle %s exceeded limit of %d in %d seconds and is blocked for the next %d seconds" % [name, limit, period, remaining_block_time] }
|
93
|
-
raise ::Prorate::Throttled.new(name, remaining_block_time)
|
90
|
+
@logger.debug { "Applying throttle counter %s" % @name }
|
91
|
+
remaining_block_time, bucket_level = run_lua_throttler(
|
92
|
+
identifier: identifier,
|
93
|
+
bucket_capacity: @limit,
|
94
|
+
leak_rate: @leak_rate,
|
95
|
+
block_for: @block_for,
|
96
|
+
n_tokens: n_tokens)
|
97
|
+
|
98
|
+
if remaining_block_time > 0
|
99
|
+
@logger.warn do
|
100
|
+
"Throttle %s exceeded limit of %d in %d seconds and is blocked for the next %d seconds" % [@name, @limit, @period, remaining_block_time]
|
94
101
|
end
|
95
|
-
|
102
|
+
raise ::Prorate::Throttled.new(@name, remaining_block_time)
|
96
103
|
end
|
104
|
+
|
105
|
+
@limit - bucket_level # Return how many calls remain
|
97
106
|
end
|
98
107
|
|
99
108
|
def status
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
+
redis_block_key = "#{identifier}.block"
|
110
|
+
@redis.with do |r|
|
111
|
+
is_blocked = redis_key_exists?(r, redis_block_key)
|
112
|
+
if is_blocked
|
113
|
+
remaining_seconds = r.get(redis_block_key).to_i - Time.now.to_i
|
114
|
+
Status.new(_is_throttled = true, remaining_seconds)
|
115
|
+
else
|
116
|
+
remaining_seconds = 0
|
117
|
+
Status.new(_is_throttled = false, remaining_seconds)
|
118
|
+
end
|
109
119
|
end
|
110
120
|
end
|
111
121
|
|
112
122
|
private
|
113
123
|
|
114
|
-
def
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
+
def identifier
|
125
|
+
discriminator = Digest::SHA1.hexdigest(Marshal.dump(@discriminators))
|
126
|
+
"#{@name}:#{discriminator}"
|
127
|
+
end
|
128
|
+
|
129
|
+
# redis-rb 4.2 started printing a warning for every single-argument use of `#exists`, because
|
130
|
+
# they intend to break compatibility in a future version (to return an integer instead of a
|
131
|
+
# boolean). The old behavior (returning a boolean) is available using the new `exists?` method.
|
132
|
+
def redis_key_exists?(redis, key)
|
133
|
+
return redis.exists?(key) if redis.respond_to?(:exists?)
|
134
|
+
redis.exists(key)
|
135
|
+
end
|
136
|
+
|
137
|
+
def run_lua_throttler(identifier:, bucket_capacity:, leak_rate:, block_for:, n_tokens:)
|
138
|
+
@redis.with do |redis|
|
139
|
+
begin
|
140
|
+
redis.evalsha(LUA_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for, n_tokens])
|
141
|
+
rescue Redis::CommandError => e
|
142
|
+
if e.message.include? "NOSCRIPT"
|
143
|
+
# The Redis server has never seen this script before. Needs to run only once in the entire lifetime
|
144
|
+
# of the Redis server, until the script changes - in which case it will be loaded under a different SHA
|
145
|
+
redis.script(:load, LUA_SCRIPT_CODE)
|
146
|
+
retry
|
147
|
+
else
|
148
|
+
raise e
|
149
|
+
end
|
150
|
+
end
|
124
151
|
end
|
125
152
|
end
|
126
153
|
|
127
|
-
class Status <
|
154
|
+
class Status < Struct.new(:is_throttled, :remaining_throttle_seconds)
|
128
155
|
def throttled?
|
129
156
|
is_throttled
|
130
157
|
end
|
data/lib/prorate/version.rb
CHANGED
data/prorate.gemspec
CHANGED
@@ -27,7 +27,6 @@ Gem::Specification.new do |spec|
|
|
27
27
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
28
|
spec.require_paths = ["lib"]
|
29
29
|
|
30
|
-
spec.add_dependency "ks"
|
31
30
|
spec.add_dependency "redis", ">= 2"
|
32
31
|
spec.add_development_dependency "connection_pool", "~> 2"
|
33
32
|
spec.add_development_dependency "bundler"
|
@@ -35,5 +34,5 @@ Gem::Specification.new do |spec|
|
|
35
34
|
spec.add_development_dependency "rspec", "~> 3.0"
|
36
35
|
spec.add_development_dependency 'wetransfer_style', '0.6.5'
|
37
36
|
spec.add_development_dependency 'yard', '~> 0.9'
|
38
|
-
spec.add_development_dependency 'pry', '~> 0.
|
37
|
+
spec.add_development_dependency 'pry', '~> 0.13.1'
|
39
38
|
end
|
metadata
CHANGED
@@ -1,29 +1,15 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: prorate
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Julik Tarkhanov
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-07-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
-
- !ruby/object:Gem::Dependency
|
14
|
-
name: ks
|
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
13
|
- !ruby/object:Gem::Dependency
|
28
14
|
name: redis
|
29
15
|
requirement: !ruby/object:Gem::Requirement
|
@@ -128,14 +114,14 @@ dependencies:
|
|
128
114
|
requirements:
|
129
115
|
- - "~>"
|
130
116
|
- !ruby/object:Gem::Version
|
131
|
-
version: 0.
|
117
|
+
version: 0.13.1
|
132
118
|
type: :development
|
133
119
|
prerelease: false
|
134
120
|
version_requirements: !ruby/object:Gem::Requirement
|
135
121
|
requirements:
|
136
122
|
- - "~>"
|
137
123
|
- !ruby/object:Gem::Version
|
138
|
-
version: 0.
|
124
|
+
version: 0.13.1
|
139
125
|
description: Can be used to implement all kinds of throttles
|
140
126
|
email:
|
141
127
|
- me@julik.nl
|
@@ -147,6 +133,7 @@ files:
|
|
147
133
|
- ".rspec"
|
148
134
|
- ".rubocop.yml"
|
149
135
|
- ".travis.yml"
|
136
|
+
- CHANGELOG.md
|
150
137
|
- Gemfile
|
151
138
|
- LICENSE.txt
|
152
139
|
- README.md
|
@@ -154,6 +141,8 @@ files:
|
|
154
141
|
- bin/console
|
155
142
|
- bin/setup
|
156
143
|
- lib/prorate.rb
|
144
|
+
- lib/prorate/leaky_bucket.lua
|
145
|
+
- lib/prorate/leaky_bucket.rb
|
157
146
|
- lib/prorate/null_logger.rb
|
158
147
|
- lib/prorate/null_pool.rb
|
159
148
|
- lib/prorate/rate_limit.lua
|
@@ -184,8 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
184
173
|
- !ruby/object:Gem::Version
|
185
174
|
version: '0'
|
186
175
|
requirements: []
|
187
|
-
|
188
|
-
rubygems_version: 2.6.11
|
176
|
+
rubygems_version: 3.0.3
|
189
177
|
signing_key:
|
190
178
|
specification_version: 4
|
191
179
|
summary: Time-restricted rate limiter using Redis
|