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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: dab868f09fd0b191abe56fdfdb156271331d397e
4
- data.tar.gz: 0c9d8670999217b9b14f662a53b589f7c3b0b93d
2
+ SHA256:
3
+ metadata.gz: bb2c78403fd3d37fd073ccf736618673e532c6fc53efd7e1c342c7edebc4037f
4
+ data.tar.gz: 497b74b1d07d1590e44f338f7150b6b75fe5ed154af82d052381915a2b174c69
5
5
  SHA512:
6
- metadata.gz: c7c9909a1ebf831a56b3216a3387598d1c72f20463962329a9d61ea993533f5298769af63f6e16d837a2b22f1f1d1008b7ae2b1413ac80ae58b264f08b72eeb5
7
- data.tar.gz: b9c3b0b86240e6d8dd9b504a3c8bc2341bd230138e263edad0fc8e03db900cb9c1ca8e777f3ac22bea5e44f00750bad13568d47c315315d09608ab4bc597cc1c
6
+ metadata.gz: d2a262971d745073dfd385088d92bf40667fa8108e6c3b71982b17fad41d6ee94472e40f8189badfa6131ac853a0dfe381e9bfbef93a0b7bbd24c3f39339251f
7
+ data.tar.gz: 33f4f60558e7cee9fd671ebe48f4e35fc67e58c2ea5ad5cde5e40368b8b486e941eabb71ddf3dfaa87a9380689d31c059d56ea81cfa8860a955409d075840824
@@ -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 of its status
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)
@@ -1,6 +1,4 @@
1
1
  require "prorate/version"
2
- require "ks"
3
- require "logger"
4
2
  require "redis"
5
3
 
6
4
  module Prorate
@@ -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
@@ -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)
@@ -4,15 +4,27 @@ module Prorate
4
4
  class MisconfiguredThrottle < StandardError
5
5
  end
6
6
 
7
- class Throttle < Ks.strict(:name, :limit, :period, :block_for, :redis, :logger)
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
- def initialize(*)
12
- super
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
- self.redis = NullPool.new(redis) unless redis.respond_to?(:with)
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
- discriminator = Digest::SHA1.hexdigest(Marshal.dump(@discriminators))
79
- identifier = [name, discriminator].join(':')
80
-
81
- redis.with do |r|
82
- logger.debug { "Applying throttle counter %s" % name }
83
- remaining_block_time, bucket_level = run_lua_throttler(
84
- redis: r,
85
- identifier: identifier,
86
- bucket_capacity: limit,
87
- leak_rate: @leak_rate,
88
- block_for: block_for,
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
- return limit - bucket_level # How many calls remain
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
- discriminator = Digest::SHA1.hexdigest(Marshal.dump(@discriminators))
101
- identifier = [name, discriminator].join(':')
102
-
103
- redis.with do |r|
104
- is_blocked = r.exists("#{identifier}.block")
105
- return Status.new(is_throttled: false, remaining_throttle_seconds: 0) unless is_blocked
106
-
107
- remaining_seconds = r.get("#{identifier}.block").to_i - Time.now.to_i
108
- Status.new(is_throttled: true, remaining_throttle_seconds: remaining_seconds)
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 run_lua_throttler(redis:, identifier:, bucket_capacity:, leak_rate:, block_for:, n_tokens:)
115
- redis.evalsha(LUA_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for, n_tokens])
116
- rescue Redis::CommandError => e
117
- if e.message.include? "NOSCRIPT"
118
- # The Redis server has never seen this script before. Needs to run only once in the entire lifetime
119
- # of the Redis server, until the script changes - in which case it will be loaded under a different SHA
120
- redis.script(:load, LUA_SCRIPT_CODE)
121
- retry
122
- else
123
- raise e
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 < Ks.strict(:is_throttled, :remaining_throttle_seconds)
154
+ class Status < Struct.new(:is_throttled, :remaining_throttle_seconds)
128
155
  def throttled?
129
156
  is_throttled
130
157
  end
@@ -1,3 +1,3 @@
1
1
  module Prorate
2
- VERSION = "0.6.0"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -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.12.2'
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.6.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-02-28 00:00:00.000000000 Z
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.12.2
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.12.2
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
- rubyforge_project:
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