gcra 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 +7 -0
- data/lib/gcra/rate_limiter.rb +100 -0
- data/lib/gcra/redis_store.rb +73 -0
- data/lib/gcra/version.rb +3 -0
- metadata +76 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 2aa36cd71aea4bfc16ba99bc633fd8c6ab2fffc6
|
4
|
+
data.tar.gz: 2a14cda68fe7237ee3b73fb9e804c845bbe50a67
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 56f422072041b5029ab157d0de4cf663b924d22dccdbf957acb241419cd35bfecde7c179f4b8d64cfd60893974daa5ea02d8c3032229ac0b6f6955f08f93cf8b
|
7
|
+
data.tar.gz: 18932fe87d83d41b2abedb7c06eca4f6e29ee75e186a744d1e5e61c1e0cb46d2fc41ea6d3161fbd095fae311f7bf26a3fd0ccdea924500baca2754bed416ad5f
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module GCRA
|
2
|
+
RateLimitInfo = Struct.new(
|
3
|
+
:limit,
|
4
|
+
:remaining,
|
5
|
+
:reset_after,
|
6
|
+
:retry_after
|
7
|
+
)
|
8
|
+
|
9
|
+
class StoreUpdateFailed < RuntimeError; end
|
10
|
+
|
11
|
+
class RateLimiter
|
12
|
+
MAX_ATTEMPTS = 10
|
13
|
+
NANO_SECOND = 1_000_000_000
|
14
|
+
|
15
|
+
# rate_period in seconds
|
16
|
+
def initialize(store, rate_period, max_burst)
|
17
|
+
@store = store
|
18
|
+
# Convert from seconds to nanoseconds. Ruby's time types return floats from calculations,
|
19
|
+
# which is not what we want. Also, there's no proper type for durations.
|
20
|
+
@emission_interval = (rate_period * NANO_SECOND).to_i
|
21
|
+
@delay_variation_tolerance = @emission_interval * (max_burst + 1)
|
22
|
+
@limit = max_burst + 1
|
23
|
+
end
|
24
|
+
|
25
|
+
def limit(key, quantity)
|
26
|
+
i = 0
|
27
|
+
|
28
|
+
while i < MAX_ATTEMPTS
|
29
|
+
# tat refers to the theoretical arrival time that would be expected
|
30
|
+
# from equally spaced requests at exactly the rate limit.
|
31
|
+
tat_from_store, now = @store.get_with_time(key)
|
32
|
+
|
33
|
+
tat = if tat_from_store.nil?
|
34
|
+
now
|
35
|
+
else
|
36
|
+
tat_from_store
|
37
|
+
end
|
38
|
+
|
39
|
+
increment = quantity * @emission_interval
|
40
|
+
|
41
|
+
# new_tat describes the new theoretical arrival if the request would succeed.
|
42
|
+
# If we get a `tat` in the past (empty bucket), use the current time instead. Having
|
43
|
+
# a delay_variation_tolerance >= 1 makes sure that at least one request with quantity 1 is
|
44
|
+
# possible when the bucket is empty.
|
45
|
+
new_tat = [now, tat].max + increment
|
46
|
+
|
47
|
+
allow_at_and_after = new_tat - @delay_variation_tolerance
|
48
|
+
if now < allow_at_and_after
|
49
|
+
|
50
|
+
info = RateLimitInfo.new
|
51
|
+
info.limit = @limit
|
52
|
+
|
53
|
+
# Bucket size in duration minus time left until TAT, divided by the emission interval
|
54
|
+
# to get a count
|
55
|
+
# This is non-zero when a request with quantity > 1 is limited, but lower quantities
|
56
|
+
# are still allowed.
|
57
|
+
info.remaining = ((@delay_variation_tolerance - (tat - now)) / @emission_interval).to_i
|
58
|
+
|
59
|
+
# Use `tat` instead of `newTat` - we don't further increment tat for a blocked request
|
60
|
+
info.reset_after = (tat - now).to_f / NANO_SECOND
|
61
|
+
|
62
|
+
# There's no point in setting retry_after if a request larger than the maximum quantity
|
63
|
+
# is attempted.
|
64
|
+
if increment <= @delay_variation_tolerance
|
65
|
+
info.retry_after = (allow_at_and_after - now).to_f / NANO_SECOND
|
66
|
+
end
|
67
|
+
|
68
|
+
return true, info
|
69
|
+
end
|
70
|
+
|
71
|
+
# Time until bucket is empty again
|
72
|
+
ttl = new_tat - now
|
73
|
+
|
74
|
+
new_value = new_tat.to_i
|
75
|
+
|
76
|
+
updated = if tat_from_store.nil?
|
77
|
+
@store.set_if_not_exists_with_ttl(key, new_value, ttl)
|
78
|
+
else
|
79
|
+
@store.compare_and_set_with_ttl(key, tat_from_store, new_value, ttl)
|
80
|
+
end
|
81
|
+
|
82
|
+
if updated
|
83
|
+
info = RateLimitInfo.new
|
84
|
+
info.limit = @limit
|
85
|
+
info.remaining = ((@delay_variation_tolerance - ttl) / @emission_interval).to_i
|
86
|
+
info.reset_after = ttl.to_f / NANO_SECOND
|
87
|
+
info.retry_after = nil
|
88
|
+
|
89
|
+
return false, info
|
90
|
+
end
|
91
|
+
|
92
|
+
i += 1
|
93
|
+
end
|
94
|
+
|
95
|
+
raise StoreUpdateFailed.new(
|
96
|
+
"Failed to store updated rate limit data for key '#{key}' after #{MAX_ATTEMPTS} attempts"
|
97
|
+
)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module GCRA
|
2
|
+
# Redis store, expects all timestamps and durations to be integers with nanoseconds since epoch.
|
3
|
+
class RedisStore
|
4
|
+
CAS_SCRIPT = <<-EOF.freeze
|
5
|
+
local v = redis.call('get', KEYS[1])
|
6
|
+
if v == false then
|
7
|
+
return redis.error_reply("key does not exist")
|
8
|
+
end
|
9
|
+
if v ~= ARGV[1] then
|
10
|
+
return 0
|
11
|
+
end
|
12
|
+
if ARGV[3] ~= "0" then
|
13
|
+
redis.call('psetex', KEYS[1], ARGV[3], ARGV[2])
|
14
|
+
else
|
15
|
+
redis.call('set', KEYS[1], ARGV[2])
|
16
|
+
end
|
17
|
+
return 1
|
18
|
+
EOF
|
19
|
+
CAS_SCRIPT_MISSING_KEY_RESPONSE = 'key does not exist'.freeze
|
20
|
+
|
21
|
+
def initialize(redis, key_prefix)
|
22
|
+
@redis = redis
|
23
|
+
@key_prefix = key_prefix
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns the value of the key or nil, if it isn't in the store.
|
27
|
+
# Also returns the time from the Redis server, with microsecond precision.
|
28
|
+
def get_with_time(key)
|
29
|
+
time_response = @redis.time # returns tuple (seconds since epoch, microseconds)
|
30
|
+
# Convert tuple to nanoseconds
|
31
|
+
time = (time_response[0] * 1_000_000 + time_response[1]) * 1_000
|
32
|
+
value = @redis.get(@key_prefix + key)
|
33
|
+
if value != nil
|
34
|
+
value = value.to_i
|
35
|
+
end
|
36
|
+
|
37
|
+
return value, time
|
38
|
+
end
|
39
|
+
|
40
|
+
# Set the value of key only if it is not already set. Return whether the value was set.
|
41
|
+
# Also set the key's expiration (ttl, in seconds). The operations are not performed atomically.
|
42
|
+
def set_if_not_exists_with_ttl(key, value, ttl_nano)
|
43
|
+
full_key = @key_prefix + key
|
44
|
+
did_set = @redis.setnx(full_key, value)
|
45
|
+
|
46
|
+
if did_set && ttl_nano > 0
|
47
|
+
ttl_milli = ttl_nano / 1_000_000
|
48
|
+
puts "TTL: #{ttl_milli}"
|
49
|
+
@redis.pexpire(full_key, ttl_milli)
|
50
|
+
end
|
51
|
+
|
52
|
+
return did_set
|
53
|
+
end
|
54
|
+
|
55
|
+
# Atomically compare the value at key to the old value. If it matches, set it to the new value
|
56
|
+
# and return true. Otherwise, return false. If the key does not exist in the store,
|
57
|
+
# return false with no error. If the swap succeeds, update the ttl for the key atomically.
|
58
|
+
def compare_and_set_with_ttl(key, old_value, new_value, ttl_nano)
|
59
|
+
full_key = @key_prefix + key
|
60
|
+
begin
|
61
|
+
ttl_milli = ttl_nano / 1_000_000
|
62
|
+
swapped = @redis.eval(CAS_SCRIPT, keys: [full_key], argv: [old_value, new_value, ttl_milli])
|
63
|
+
rescue Redis::CommandError => e
|
64
|
+
if e.message == CAS_SCRIPT_MISSING_KEY_RESPONSE
|
65
|
+
return false
|
66
|
+
end
|
67
|
+
raise
|
68
|
+
end
|
69
|
+
|
70
|
+
return swapped == 1
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
data/lib/gcra/version.rb
ADDED
metadata
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gcra
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Michael Frister
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-02-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.5'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.3'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.3'
|
41
|
+
description: GCRA implementation for rate limiting
|
42
|
+
email:
|
43
|
+
- michael.frister@barzahlen.de
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- lib/gcra/rate_limiter.rb
|
49
|
+
- lib/gcra/redis_store.rb
|
50
|
+
- lib/gcra/version.rb
|
51
|
+
homepage: https://github.com/Barzahlen/gcra
|
52
|
+
licenses:
|
53
|
+
- MIT
|
54
|
+
metadata: {}
|
55
|
+
post_install_message:
|
56
|
+
rdoc_options: []
|
57
|
+
require_paths:
|
58
|
+
- lib
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - ">="
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
requirements: []
|
70
|
+
rubyforge_project:
|
71
|
+
rubygems_version: 2.4.8
|
72
|
+
signing_key:
|
73
|
+
specification_version: 4
|
74
|
+
summary: Ruby implementation of a generic cell rate algorithm (GCRA), ported from
|
75
|
+
the Go implementation throttled.
|
76
|
+
test_files: []
|