prorate 0.4.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +5 -2
- data/Rakefile +7 -0
- data/lib/prorate/rate_limit.lua +21 -19
- data/lib/prorate/throttle.rb +73 -21
- data/lib/prorate/throttled.rb +13 -1
- data/lib/prorate/version.rb +1 -1
- data/prorate.gemspec +1 -0
- metadata +16 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 89ad7bfadc58561263e35de3de2a0eef9c4b5aca
|
4
|
+
data.tar.gz: 51591b0aabe76204c4a9029a095150faf434c84d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5393f7eb13d4bd9236f3809c90215ae1a9700dd20a17c195db4180a526502278055365fc8e5dfce4644bba737a808cd738e7e6d3120f07ff733d2a8d214a7749
|
7
|
+
data.tar.gz: 604f4fd70fd8d9506cdccff6e819d91d2e26eae94e70ba00f3cf212118ca5d2fb2eeab8052c0702bea4a76d423be93c860dded6de64cd140ad1ddacad9ea1e2e
|
data/README.md
CHANGED
@@ -4,6 +4,9 @@ Provides a low-level time-based throttle. Is mainly meant for situations where u
|
|
4
4
|
useful since you need access to more variables. Under the hood, this uses a Lua script that implements the
|
5
5
|
[Leaky Bucket](https://en.wikipedia.org/wiki/Leaky_bucket) algorithm in a single threaded and race condition safe way.
|
6
6
|
|
7
|
+
[![Build Status](https://travis-ci.org/WeTransfer/prorate.svg?branch=master)](https://travis-ci.org/WeTransfer/prorate)
|
8
|
+
[![Gem Version](https://badge.fury.io/rb/prorate.svg)](https://badge.fury.io/rb/prorate)
|
9
|
+
|
7
10
|
## Installation
|
8
11
|
|
9
12
|
Add this line to your application's Gemfile:
|
@@ -27,14 +30,14 @@ Within your Rails controller:
|
|
27
30
|
t = Prorate::Throttle.new(redis: Redis.new, logger: Rails.logger,
|
28
31
|
name: "throttle-login-email", limit: 20, period: 5.seconds)
|
29
32
|
# Add all the parameters that function as a discriminator
|
30
|
-
t << request.ip
|
31
|
-
t << params.require(:email)
|
33
|
+
t << request.ip << params.require(:email)
|
32
34
|
# ...and call the throttle! method
|
33
35
|
t.throttle! # Will raise a Prorate::Throttled exception if the limit has been reached
|
34
36
|
|
35
37
|
To capture that exception, in the controller
|
36
38
|
|
37
39
|
rescue_from Prorate::Throttled do |e|
|
40
|
+
response.set_header('Retry-After', e.retry_in_seconds.to_s)
|
38
41
|
render nothing: true, status: 429
|
39
42
|
end
|
40
43
|
|
data/Rakefile
CHANGED
@@ -1,6 +1,13 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
2
|
require "rspec/core/rake_task"
|
3
3
|
require 'rubocop/rake_task'
|
4
|
+
require 'yard'
|
5
|
+
|
6
|
+
YARD::Rake::YardocTask.new(:doc) do |t|
|
7
|
+
# The dash has to be between the two to "divide" the source files and
|
8
|
+
# miscellaneous documentation files that contain no code
|
9
|
+
t.files = ['lib/**/*.rb', '-', 'LICENSE.txt']
|
10
|
+
end
|
4
11
|
|
5
12
|
RSpec::Core::RakeTask.new(:spec)
|
6
13
|
RuboCop::RakeTask.new(:rubocop)
|
data/lib/prorate/rate_limit.lua
CHANGED
@@ -14,6 +14,7 @@ local block_key = ARGV[1] .. ".block"
|
|
14
14
|
local max_bucket_capacity = tonumber(ARGV[2])
|
15
15
|
local leak_rate = tonumber(ARGV[3])
|
16
16
|
local block_duration = tonumber(ARGV[4])
|
17
|
+
local n_tokens = tonumber(ARGV[5]) -- How many tokens this call adds to the bucket. Defaults to 1
|
17
18
|
local now = tonumber(redis.call("TIME")[1]) --unix timestamp, will be required in all paths
|
18
19
|
|
19
20
|
local key_lifetime = math.ceil(max_bucket_capacity / leak_rate)
|
@@ -23,28 +24,29 @@ if blocked_until then
|
|
23
24
|
return {(tonumber(blocked_until) - now), 0}
|
24
25
|
end
|
25
26
|
|
26
|
-
-- get current bucket level
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
27
|
+
-- get current bucket level. The throttle key might not exist yet in which
|
28
|
+
-- case we default to 0
|
29
|
+
local bucket_level = tonumber(redis.call("GET", bucket_level_key)) or 0
|
30
|
+
|
31
|
+
-- ...and then perform the leaky bucket fillup/leak. We need to do this also when the bucket has
|
32
|
+
-- just been created because the initial n_tokens to add might be so high that it will
|
33
|
+
-- immediately overflow the bucket and trigger the throttle, on the first call.
|
34
|
+
local last_updated = tonumber(redis.call("GET", last_updated_key)) or now -- use sensible default of 'now' if the key does not exist
|
35
|
+
local new_bucket_level = math.max(0, bucket_level - (leak_rate * (now - last_updated)))
|
36
|
+
|
37
|
+
if (new_bucket_level + n_tokens) <= max_bucket_capacity then
|
38
|
+
new_bucket_level = math.max(0, new_bucket_level + n_tokens)
|
39
|
+
retval = {0, math.ceil(new_bucket_level)}
|
32
40
|
else
|
33
|
-
|
34
|
-
|
35
|
-
local new_bucket_level = math.max(0, bucket_level - (leak_rate * (now - last_updated)))
|
36
|
-
|
37
|
-
if (new_bucket_level + 1) <= max_bucket_capacity then
|
38
|
-
new_bucket_level = new_bucket_level + 1
|
39
|
-
retval = {0, math.ceil(new_bucket_level)}
|
40
|
-
else
|
41
|
-
redis.call("SETEX", block_key, block_duration, now + block_duration)
|
42
|
-
retval = {block_duration, 0}
|
43
|
-
end
|
44
|
-
redis.call("SETEX", bucket_level_key, key_lifetime, new_bucket_level) --still needs to be saved
|
41
|
+
redis.call("SETEX", block_key, block_duration, now + block_duration)
|
42
|
+
retval = {block_duration, 0}
|
45
43
|
end
|
46
44
|
|
47
|
-
--
|
45
|
+
-- Save the new bucket level
|
46
|
+
redis.call("SETEX", bucket_level_key, key_lifetime, new_bucket_level)
|
47
|
+
|
48
|
+
-- Record when we updated the bucket so that the amount of tokens leaked
|
49
|
+
-- can be correctly determined on the next invocation
|
48
50
|
redis.call("SETEX", last_updated_key, key_lifetime, now)
|
49
51
|
|
50
52
|
return retval
|
data/lib/prorate/throttle.rb
CHANGED
@@ -1,20 +1,12 @@
|
|
1
1
|
require 'digest'
|
2
2
|
|
3
3
|
module Prorate
|
4
|
-
class ScriptHashMismatch < StandardError
|
5
|
-
end
|
6
|
-
|
7
4
|
class MisconfiguredThrottle < StandardError
|
8
5
|
end
|
9
6
|
|
10
7
|
class Throttle < Ks.strict(:name, :limit, :period, :block_for, :redis, :logger)
|
11
|
-
|
12
|
-
|
13
|
-
script = File.read(script_filepath)
|
14
|
-
Digest::SHA1.hexdigest(script)
|
15
|
-
end
|
16
|
-
|
17
|
-
CURRENT_SCRIPT_HASH = lua_script_hash
|
8
|
+
LUA_SCRIPT_CODE = File.read(File.join(__dir__, "rate_limit.lua"))
|
9
|
+
LUA_SCRIPT_HASH = Digest::SHA1.hexdigest(LUA_SCRIPT_CODE)
|
18
10
|
|
19
11
|
def initialize(*)
|
20
12
|
super
|
@@ -24,17 +16,77 @@ module Prorate
|
|
24
16
|
@leak_rate = limit.to_f / period # tokens per second;
|
25
17
|
end
|
26
18
|
|
19
|
+
# Add a value that will be used to distinguish this throttle from others.
|
20
|
+
# It has to be something user- or connection-specific, and multiple
|
21
|
+
# discriminators can be combined:
|
22
|
+
#
|
23
|
+
# throttle << ip_address << user_agent_fingerprint
|
24
|
+
#
|
25
|
+
# @param discriminator[Object] a Ruby object that can be marshaled
|
26
|
+
# in an equivalent way between requests, using `Marshal.dump
|
27
27
|
def <<(discriminator)
|
28
28
|
@discriminators << discriminator
|
29
29
|
end
|
30
30
|
|
31
|
-
|
31
|
+
# Applies the throttle and raises a {Throttled} exception if it has been triggered
|
32
|
+
#
|
33
|
+
# Accepts an optional number of tokens to put in the bucket (default is 1).
|
34
|
+
# The effect of `n_tokens:` set to 0 is a "ping".
|
35
|
+
# It makes sure the throttle keys in Redis get created and adjusts the
|
36
|
+
# last invoked time of the leaky bucket. Can be used when a throttle
|
37
|
+
# is applied in a "shadow" fashion. For example, imagine you
|
38
|
+
# have a cascade of throttles with the following block times:
|
39
|
+
#
|
40
|
+
# Throttle A: [-------]
|
41
|
+
# Throttle B: [----------]
|
42
|
+
#
|
43
|
+
# You apply Throttle A: and it fires, but when that happens you also
|
44
|
+
# want to enable a throttle that is applied to "repeat offenders" only -
|
45
|
+
# - for instance ones that probe for tokens and/or passwords.
|
46
|
+
#
|
47
|
+
# Throttle C: [-------------------------------]
|
48
|
+
#
|
49
|
+
# If your "Throttle A" fires, you can trigger Throttle C
|
50
|
+
#
|
51
|
+
# Throttle A: [-----|-]
|
52
|
+
# Throttle C: [-----|-------------------------]
|
53
|
+
#
|
54
|
+
# because you know that Throttle A has fired and thus Throttle C comes
|
55
|
+
# into effect. What you want to do, however, is to fire Throttle C
|
56
|
+
# even though Throttle A: would have unlatched, which would create this
|
57
|
+
# call sequence:
|
58
|
+
#
|
59
|
+
# Throttle A: [-------] *(A not triggered)
|
60
|
+
# Throttle C: [------------|------------------]
|
61
|
+
#
|
62
|
+
# To achieve that you can keep Throttle C alive using `throttle!(n_tokens: 0)`,
|
63
|
+
# on every check that touches Throttle A and/or Throttle C. It keeps the leaky bucket
|
64
|
+
# updated but does not add any tokens to it:
|
65
|
+
#
|
66
|
+
# Throttle A: [------] *(A not triggered since block period has ended)
|
67
|
+
# Throttle C: [-----------|(ping)------------------] C is still blocking
|
68
|
+
#
|
69
|
+
# So you can effectively "keep a throttle alive" without ever triggering it,
|
70
|
+
# or keep it alive in combination with other throttles.
|
71
|
+
#
|
72
|
+
# @param n_tokens[Integer] the number of tokens to put in the bucket. If you are
|
73
|
+
# using Prorate for rate limiting, and a single request is adding N objects to your
|
74
|
+
# database for example, you can "top up" the bucket with a set number of tokens
|
75
|
+
# with a arbitrary ratio - like 1 token per inserted row. Once the bucket fills up
|
76
|
+
# the Throttled exception is going to be raised. Defaults to 1.
|
77
|
+
def throttle!(n_tokens: 1)
|
32
78
|
discriminator = Digest::SHA1.hexdigest(Marshal.dump(@discriminators))
|
33
79
|
identifier = [name, discriminator].join(':')
|
34
80
|
|
35
81
|
redis.with do |r|
|
36
|
-
logger.
|
37
|
-
remaining_block_time, bucket_level = run_lua_throttler(
|
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)
|
38
90
|
|
39
91
|
if remaining_block_time > 0
|
40
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] }
|
@@ -44,16 +96,16 @@ module Prorate
|
|
44
96
|
end
|
45
97
|
end
|
46
98
|
|
47
|
-
|
48
|
-
|
99
|
+
private
|
100
|
+
|
101
|
+
def run_lua_throttler(redis:, identifier:, bucket_capacity:, leak_rate:, block_for:, n_tokens:)
|
102
|
+
redis.evalsha(LUA_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for, n_tokens])
|
49
103
|
rescue Redis::CommandError => e
|
50
104
|
if e.message.include? "NOSCRIPT"
|
51
|
-
# The Redis server has never seen this script before. Needs to run only once in the entire lifetime
|
52
|
-
|
53
|
-
script
|
54
|
-
|
55
|
-
redis.script(:load, script)
|
56
|
-
redis.evalsha(CURRENT_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for])
|
105
|
+
# The Redis server has never seen this script before. Needs to run only once in the entire lifetime
|
106
|
+
# of the Redis server, until the script changes - in which case it will be loaded under a different SHA
|
107
|
+
redis.script(:load, LUA_SCRIPT_CODE)
|
108
|
+
retry
|
57
109
|
else
|
58
110
|
raise e
|
59
111
|
end
|
data/lib/prorate/throttled.rb
CHANGED
@@ -1,5 +1,17 @@
|
|
1
|
+
# The Throttled exception gets raised when a throttle is triggered.
|
2
|
+
#
|
3
|
+
# The exception carries additional attributes which can be used for
|
4
|
+
# error tracking and for creating a correct Retry-After HTTP header for
|
5
|
+
# a 429 response
|
1
6
|
class Prorate::Throttled < StandardError
|
2
|
-
|
7
|
+
# @attr [String] the name of the throttle (like "shpongs-per-ip").
|
8
|
+
# Can be used to detect which throttle has fired when multiple
|
9
|
+
# throttles are used within the same block.
|
10
|
+
attr_reader :throttle_name
|
11
|
+
|
12
|
+
# @attr [Integer] for how long the caller will be blocked, in seconds.
|
13
|
+
attr_reader :retry_in_seconds
|
14
|
+
|
3
15
|
def initialize(throttle_name, try_again_in)
|
4
16
|
@throttle_name = throttle_name
|
5
17
|
@retry_in_seconds = try_again_in
|
data/lib/prorate/version.rb
CHANGED
data/prorate.gemspec
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: prorate
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.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: 2019-08-
|
11
|
+
date: 2019-08-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: ks
|
@@ -108,6 +108,20 @@ dependencies:
|
|
108
108
|
- - '='
|
109
109
|
- !ruby/object:Gem::Version
|
110
110
|
version: 0.6.0
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: yard
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0.9'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0.9'
|
111
125
|
description: Can be used to implement all kinds of throttles
|
112
126
|
email:
|
113
127
|
- me@julik.nl
|