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.
@@ -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: []