gcra 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,3 @@
1
+ module GCRA
2
+ VERSION = '1.0.0'.freeze
3
+ end
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: []