├── .github └── workflows │ └── test.yml ├── .rspec ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── gcra.gemspec ├── lib └── gcra │ ├── rate_limiter.rb │ ├── redis_store.rb │ └── version.rb └── spec ├── lib └── gcra │ ├── rate_limiter_spec.rb │ └── redis_store_spec.rb └── spec_helper.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: master 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | tests: 11 | name: Tests 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: ['3.0', '3.1', '3.2', '3.3'] 18 | redis-version: [4, 5, 6, 7] 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v2 23 | 24 | - name: Start Redis 25 | uses: supercharge/redis-github-action@1.4.0 26 | with: 27 | redis-version: ${{ matrix.redis-version }} 28 | 29 | - name: Install Ruby ${{ matrix.ruby }} 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby }} 33 | 34 | - name: Install dependencies 35 | run: | 36 | gem install bundler -v '2.5.7' --no-document 37 | bundle install --jobs 4 --retry 3 38 | 39 | - name: Run Tests 40 | run: | 41 | bundle exec rspec 42 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Metrics/LineLength: 2 | Max: 100 3 | Style/BlockDelimiters: 4 | EnforcedStyle: semantic 5 | Style/ClassAndModuleChildren: 6 | # We need nested style for logic require files, but use compact style for logic implementations. 7 | Enabled: False 8 | Style/Documentation: 9 | Enabled: False 10 | Style/GuardClause: 11 | # With 1, this catches a few false positives (e.g. a check at the end of a function that raises an 12 | # exception) 13 | MinBodyLength: 2 14 | Style/IfUnlessModifier: 15 | Enabled: False 16 | Style/NonNilCheck: 17 | Enabled: False 18 | Style/NumericLiterals: 19 | # Make sample division public ids (with 5 digits) usable without underscores. 20 | MinDigits: 6 21 | Style/PreferredHashMethods: 22 | # While only using a single method when aliases are available, `has_key?` is a much more obvious 23 | # and faster to understand method name than `key?`. 24 | Enabled: False 25 | Style/RaiseArgs: 26 | # We regularly use exceptions without an additional message within logic 27 | Enabled: False 28 | Style/RedundantReturn: 29 | # Using 'return' to explicitly show that the result of a function call is supposed to be returned 30 | # compared to the function just being executed totally makes sense. 31 | Enabled: False 32 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | gcra (1.3.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | connection_pool (2.4.1) 10 | diff-lcs (1.5.0) 11 | redis (5.1.0) 12 | redis-client (>= 0.17.0) 13 | redis-client (0.21.1) 14 | connection_pool 15 | rspec (3.12.0) 16 | rspec-core (~> 3.12.0) 17 | rspec-expectations (~> 3.12.0) 18 | rspec-mocks (~> 3.12.0) 19 | rspec-core (3.12.0) 20 | rspec-support (~> 3.12.0) 21 | rspec-expectations (3.12.2) 22 | diff-lcs (>= 1.2.0, < 2.0) 23 | rspec-support (~> 3.12.0) 24 | rspec-mocks (3.12.2) 25 | diff-lcs (>= 1.2.0, < 2.0) 26 | rspec-support (~> 3.12.0) 27 | rspec-support (3.12.0) 28 | 29 | PLATFORMS 30 | ruby 31 | 32 | DEPENDENCIES 33 | gcra! 34 | redis (~> 5.1) 35 | rspec (~> 3.12) 36 | 37 | BUNDLED WITH 38 | 2.5.7 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 viafintech GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GCRA for Ruby 2 | 3 | ![Build Status](https://github.com/viafintech/gcra-ruby/actions/workflows/test.yml/badge.svg) [![Code Climate](https://codeclimate.com/github/Barzahlen/gcra-ruby/badges/gpa.svg)](https://codeclimate.com/github/Barzahlen/gcra-ruby) [![RubyDoc](https://img.shields.io/badge/ruby-doc-green.svg)](http://rubydoc.info/github/Barzahlen/gcra-ruby) 4 | 5 | `gcra` is a Ruby implementation of a [generic cell rate algorithm](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm) (GCRA), ported from and data-format compatible with the Go implementation [throttled](https://github.com/throttled/throttled). It's useful for rate limiting (e.g. for HTTP requests) and allows weights specified per request. 6 | 7 | ## Getting Started 8 | 9 | gcra currently uses Redis (>= 2.6.12 for EVALSHA, SCRIPT LOAD, SEX with NX and PX) as a data store, although it supports other store implementations. 10 | 11 | Add to your `Gemfile`: 12 | 13 | ```ruby 14 | gem 'gcra' 15 | gem 'redis' 16 | ``` 17 | 18 | Create Redis, RedisStore and RateLimiter instances: 19 | 20 | ```ruby 21 | require 'redis' 22 | require 'gcra/rate_limiter' 23 | require 'gcra/redis_store' 24 | 25 | redis = Redis.new(host: 'localhost', port: 6379, timeout: 0.1) 26 | key_prefix = 'rate-limit-app1:' 27 | store = GCRA::RedisStore.new( 28 | redis, 29 | key_prefix, 30 | { reconnect_on_readonly: false }, 31 | ) 32 | 33 | rate_period = 0.5 # Two requests per second 34 | max_burst = 10 # Allow 10 additional requests as a burst 35 | limiter = GCRA::RateLimiter.new(store, rate_period, max_burst) 36 | ``` 37 | 38 | * `rate_period`: Period between two requests, allowed as a sustained rate. Example: 0.1 for 10 requests per second 39 | * `max_burst`: Number of requests allowed as a burst in addition to the sustained rate. If the burst is used up, one additional request allowed as burst 'comes back' after each `rate_period` where no request was made. 40 | 41 | Rate limit a request (call this before each request): 42 | 43 | ```ruby 44 | key = '123' # e.g. an account identifier 45 | quantity = 1 # the number of requests 'used up' by this request, useful e.g. for batch requests 46 | 47 | exceeded, info = limiter.limit(key, quantity) 48 | # => [false, #] 49 | ``` 50 | 51 | * `exceeded`: `false` means the request should be allowed, `true` means the request would exceed the limit and should be blocked. 52 | * `info`: `GCRA::RateLimitInfo` contains information that might be useful for your API users. It's a `Struct` with the following fields: 53 | - `limit`: Contains the number of requests that can be made if no previous requests have been made (or they were long enough ago). That's `max_burst` plus one. The latter is necessary so requests are allowed at all when `max_burst` is set to zero. 54 | - `remaining`: The number of remaining requests that can be made immediately, i.e. the remaining burst. 55 | - `reset_after`: The time in seconds until the full burst will be available again. 56 | - `retry_after`: Set to `nil` if a request is allowed or it otherwise doesn't make sense to retry a request (if `quantity` is larger than `max_burst`). For a blocked request that can be retried later, set to the duration in seconds until the next request with the given quantity will be allowed. 57 | 58 | `RateLimiter#limit` only tells you whether to limit a request or not. You'll have to react to its response yourself and e.g. return an error message and stop processing a request if the limit was exceeded. 59 | 60 | ## License 61 | 62 | [MIT](LICENSE) 63 | -------------------------------------------------------------------------------- /gcra.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib/', __FILE__) 2 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) 3 | require 'gcra/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'gcra' 7 | spec.version = GCRA::VERSION 8 | spec.authors = ['Michael Frister', 'Tobias Schoknecht'] 9 | spec.email = ['tobias.schoknecht@viafintech.com'] 10 | spec.description = 'GCRA implementation for rate limiting' 11 | spec.summary = 'Ruby implementation of a generic cell rate algorithm (GCRA), ported from ' \ 12 | 'the Go implementation throttled.' 13 | spec.homepage = 'https://github.com/viafintech/gcra-ruby' 14 | spec.license = 'MIT' 15 | 16 | spec.files = Dir['lib/**/*.rb'] 17 | spec.test_files = spec.files.grep(%r{^spec/}) 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_development_dependency 'rspec', '~> 3.12' 21 | spec.add_development_dependency 'redis', '~> 5.1' 22 | end 23 | -------------------------------------------------------------------------------- /lib/gcra/rate_limiter.rb: -------------------------------------------------------------------------------- 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 | key = key.to_s unless key.is_a?(String) 27 | i = 0 28 | 29 | while i < MAX_ATTEMPTS 30 | # tat refers to the theoretical arrival time that would be expected 31 | # from equally spaced requests at exactly the rate limit. 32 | tat_from_store, now = @store.get_with_time(key) 33 | 34 | tat = if tat_from_store.nil? 35 | now 36 | else 37 | tat_from_store 38 | end 39 | 40 | increment = quantity * @emission_interval 41 | 42 | # new_tat describes the new theoretical arrival if the request would succeed. 43 | # If we get a `tat` in the past (empty bucket), use the current time instead. Having 44 | # a delay_variation_tolerance >= 1 makes sure that at least one request with quantity 1 is 45 | # possible when the bucket is empty. 46 | new_tat = [now, tat].max + increment 47 | 48 | allow_at_and_after = new_tat - @delay_variation_tolerance 49 | if now < allow_at_and_after 50 | 51 | info = RateLimitInfo.new 52 | info.limit = @limit 53 | 54 | # Bucket size in duration minus time left until TAT, divided by the emission interval 55 | # to get a count 56 | # This is non-zero when a request with quantity > 1 is limited, but lower quantities 57 | # are still allowed. 58 | info.remaining = ((@delay_variation_tolerance - (tat - now)) / @emission_interval).to_i 59 | 60 | # Use `tat` instead of `newTat` - we don't further increment tat for a blocked request 61 | info.reset_after = (tat - now).to_f / NANO_SECOND 62 | 63 | # There's no point in setting retry_after if a request larger than the maximum quantity 64 | # is attempted. 65 | if increment <= @delay_variation_tolerance 66 | info.retry_after = (allow_at_and_after - now).to_f / NANO_SECOND 67 | end 68 | 69 | return true, info 70 | end 71 | 72 | # Time until bucket is empty again 73 | ttl = new_tat - now 74 | 75 | new_value = new_tat.to_i 76 | 77 | updated = if tat_from_store.nil? 78 | @store.set_if_not_exists_with_ttl(key, new_value, ttl) 79 | else 80 | @store.compare_and_set_with_ttl(key, tat_from_store, new_value, ttl) 81 | end 82 | 83 | if updated 84 | info = RateLimitInfo.new 85 | info.limit = @limit 86 | info.remaining = ((@delay_variation_tolerance - ttl) / @emission_interval).to_i 87 | info.reset_after = ttl.to_f / NANO_SECOND 88 | info.retry_after = nil 89 | 90 | return false, info 91 | end 92 | 93 | i += 1 94 | end 95 | 96 | raise StoreUpdateFailed.new( 97 | "Failed to store updated rate limit data for key '#{key}' after #{MAX_ATTEMPTS} attempts" 98 | ) 99 | end 100 | 101 | # Overwrite the stored value for key to that of a bucket that has 102 | # just overflowed, ignoring any existing stored data. 103 | def mark_overflowed(key) 104 | key = key.to_s unless key.is_a?(String) 105 | i = 0 106 | while i < MAX_ATTEMPTS 107 | tat_from_store, now = @store.get_with_time(key) 108 | new_value = now + @delay_variation_tolerance 109 | ttl = @delay_variation_tolerance 110 | updated = if tat_from_store.nil? 111 | @store.set_if_not_exists_with_ttl(key, new_value, ttl) 112 | else 113 | @store.compare_and_set_with_ttl(key, tat_from_store, new_value, ttl) 114 | end 115 | if updated 116 | return true 117 | end 118 | i += 1 119 | end 120 | 121 | raise StoreUpdateFailed.new( 122 | "Failed to store updated rate limit data for key '#{key}' after #{MAX_ATTEMPTS} attempts" 123 | ) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/gcra/redis_store.rb: -------------------------------------------------------------------------------- 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 | redis.call('psetex', KEYS[1], ARGV[3], ARGV[2]) 13 | return 1 14 | EOF 15 | 16 | # Digest::SHA1.hexdigest(CAS_SCRIPT) 17 | CAS_SHA = "89118e702230c0d65969c5fc557a6e942a2f4d31".freeze 18 | CAS_SCRIPT_MISSING_KEY_RESPONSE_PATTERN = Regexp.new('^key does not exist') 19 | SCRIPT_NOT_IN_CACHE_RESPONSE_PATTERN = Regexp.new( 20 | '^NOSCRIPT No matching script. Please use EVAL.', 21 | ) 22 | 23 | def initialize(redis, key_prefix, options = {}) 24 | @redis = redis 25 | @key_prefix = key_prefix 26 | 27 | @reconnect_on_readonly = options[:reconnect_on_readonly] || false 28 | end 29 | 30 | # Returns the value of the key or nil, if it isn't in the store. 31 | # Also returns the time from the Redis server, with microsecond precision. 32 | def get_with_time(key) 33 | time_response, value = @redis.pipelined do |pipeline| 34 | pipeline.time # returns tuple (seconds since epoch, microseconds) 35 | pipeline.get(@key_prefix + key) 36 | end 37 | # Convert tuple to nanoseconds 38 | time = (time_response[0] * 1_000_000 + time_response[1]) * 1_000 39 | if value != nil 40 | value = value.to_i 41 | end 42 | 43 | return value, time 44 | end 45 | 46 | # Set the value of key only if it is not already set. Return whether the value was set. 47 | # Also set the key's expiration (ttl, in seconds). 48 | def set_if_not_exists_with_ttl(key, value, ttl_nano) 49 | full_key = @key_prefix + key 50 | retried = false 51 | begin 52 | ttl_milli = calculate_ttl_milli(ttl_nano) 53 | @redis.set(full_key, value, nx: true, px: ttl_milli) 54 | rescue Redis::ReadOnlyError => e 55 | if @reconnect_on_readonly && !retried 56 | @redis.close 57 | retried = true 58 | retry 59 | end 60 | raise 61 | end 62 | end 63 | 64 | # Atomically compare the value at key to the old value. If it matches, set it to the new value 65 | # and return true. Otherwise, return false. If the key does not exist in the store, 66 | # return false with no error. If the swap succeeds, update the ttl for the key atomically. 67 | def compare_and_set_with_ttl(key, old_value, new_value, ttl_nano) 68 | full_key = @key_prefix + key 69 | retried = false 70 | begin 71 | ttl_milli = calculate_ttl_milli(ttl_nano) 72 | swapped = @redis.evalsha(CAS_SHA, keys: [full_key], argv: [old_value, new_value, ttl_milli]) 73 | rescue Redis::ReadOnlyError => e 74 | if @reconnect_on_readonly && !retried 75 | @redis.close 76 | retried = true 77 | retry 78 | end 79 | raise 80 | rescue Redis::CommandError => e 81 | if e.message =~ CAS_SCRIPT_MISSING_KEY_RESPONSE_PATTERN 82 | return false 83 | elsif e.message =~ SCRIPT_NOT_IN_CACHE_RESPONSE_PATTERN && !retried 84 | @redis.script('load', CAS_SCRIPT) 85 | retried = true 86 | retry 87 | end 88 | raise 89 | end 90 | 91 | return swapped == 1 92 | end 93 | 94 | private 95 | 96 | def calculate_ttl_milli(ttl_nano) 97 | ttl_milli = ttl_nano / 1_000_000 98 | # Setting 0 as expiration/ttl would result in an error. 99 | # Therefore overwrite it and use 1 100 | if ttl_milli == 0 101 | return 1 102 | end 103 | return ttl_milli 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/gcra/version.rb: -------------------------------------------------------------------------------- 1 | module GCRA 2 | VERSION = '1.3.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/lib/gcra/rate_limiter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require_relative '../../../lib/gcra/rate_limiter' 4 | 5 | describe GCRA do 6 | before(:all) do 7 | @limit = 5 8 | 9 | @store = TestStore.new 10 | @limiter = GCRA::RateLimiter.new(@store, 1, @limit - 1) 11 | end 12 | 13 | start = 0 14 | 15 | cases = { 16 | # You can never make a request larger than the maximum 17 | 0 => { 18 | now: start, 19 | quantity: 6, 20 | exp_remaining: 5, 21 | exp_reset: 0, 22 | exp_retry: nil, 23 | exp_limited: true 24 | }, 25 | # Rate limit normal requests appropriately 26 | 1 => { 27 | now: start, 28 | quantity: 1, 29 | exp_remaining: 4, 30 | exp_reset: 1.0, 31 | exp_retry: nil, 32 | exp_limited: false 33 | }, 34 | 2 => { 35 | now: start, 36 | quantity: 1, 37 | exp_remaining: 3, 38 | exp_reset: 2 * 1.0, 39 | exp_retry: nil, 40 | exp_limited: false 41 | }, 42 | 3 => { 43 | now: start, 44 | quantity: 1, 45 | exp_remaining: 2, 46 | exp_reset: 3 * 1.0, 47 | exp_retry: nil, 48 | exp_limited: false 49 | }, 50 | 4 => { 51 | now: start, 52 | quantity: 1, 53 | exp_remaining: 1, 54 | exp_reset: 4 * 1.0, 55 | exp_retry: nil, 56 | exp_limited: false 57 | }, 58 | 5 => { 59 | now: start, 60 | quantity: 1, 61 | exp_remaining: 0, 62 | exp_reset: 5 * 1.0, 63 | exp_retry: nil, 64 | exp_limited: false 65 | }, 66 | 6 => { 67 | now: start, 68 | quantity: 1, 69 | exp_remaining: 0, 70 | exp_reset: 5 * 1.0, 71 | exp_retry: 1.0, 72 | exp_limited: true 73 | }, 74 | 7 => { 75 | now: start + (3000 * 1_000_000), # +3000 milliseconds in nanoseconds 76 | quantity: 1, 77 | exp_remaining: 2, 78 | exp_reset: 3, 79 | exp_retry: nil, 80 | exp_limited: false 81 | }, 82 | 8 => { 83 | now: start + (3100 * 1_000_000), 84 | quantity: 1, 85 | exp_remaining: 1, 86 | exp_reset: 3.9, 87 | exp_retry: nil, 88 | exp_limited: false 89 | }, 90 | 9 => { 91 | now: start + (4000 * 1_000_000), 92 | quantity: 1, 93 | exp_remaining: 1, 94 | exp_reset: 4.0, 95 | exp_retry: nil, 96 | exp_limited: false 97 | }, 98 | 10 => { 99 | now: start + (8000 * 1_000_000), 100 | quantity: 1, 101 | exp_remaining: 4, 102 | exp_reset: 1.0, 103 | exp_retry: nil, 104 | exp_limited: false 105 | }, 106 | 11 => { 107 | now: start + (9500 * 1_000_000), 108 | quantity: 1, 109 | exp_remaining: 4, 110 | exp_reset: 1.0, 111 | exp_retry: nil, 112 | exp_limited: false 113 | }, 114 | # Zero-quantity request just peeks at the state 115 | 12 => { 116 | now: start + (9500 * 1_000_000), 117 | quantity: 0, 118 | exp_remaining: 4, 119 | exp_reset: 1.0, 120 | exp_retry: nil, 121 | exp_limited: false 122 | }, 123 | # High-quantity request uses up more of the limit 124 | 13 => { 125 | now: start + (9500 * 1_000_000), 126 | quantity: 2, 127 | exp_remaining: 2, 128 | exp_reset: 3.0, 129 | exp_retry: nil, 130 | exp_limited: false 131 | }, 132 | # Large requests cannot exceed limits 133 | 14 => { 134 | now: start + (9500 * 1_000_000), 135 | quantity: 5, 136 | exp_remaining: 2, 137 | exp_reset: 3.0, 138 | exp_retry: 3, 139 | exp_limited: true 140 | } 141 | } 142 | 143 | # All cases are run consecutively through the same limiter 144 | cases.each do |i, c| 145 | it "blocks request #{i}: #{c[:exp_limited]}" do 146 | @store.now = c[:now] 147 | limited, info = @limiter.limit('foo', c[:quantity]) 148 | 149 | aggregate_failures do 150 | expect(limited).to eq(c[:exp_limited]) 151 | expect(info.limit).to eq(@limit) 152 | expect(info.remaining).to eq(c[:exp_remaining]) 153 | expect(info.reset_after).to eq(c[:exp_reset]) 154 | expect(info.retry_after).to eq(c[:exp_retry]) 155 | end 156 | end 157 | end 158 | 159 | it 'raises an exception if updating the store fails' do 160 | limit = 5 161 | store = TestStore.new 162 | store.fail_sets = true 163 | limiter = GCRA::RateLimiter.new(store, 1, limit - 1) 164 | 165 | expect { 166 | limiter.limit('foo', 1) 167 | }.to raise_error( 168 | GCRA::StoreUpdateFailed, 169 | "Failed to store updated rate limit data for key 'foo' after 10 attempts" 170 | ) 171 | end 172 | 173 | describe 'mark_overflowed' do 174 | it 'marks a key with previous data as being out of quota' do 175 | limit = 5 176 | rate_period = 1.0 # per second 177 | store = TestStore.new 178 | limiter = GCRA::RateLimiter.new(store, rate_period, limit) 179 | limited, info = limiter.limit('foo', 1) 180 | expect(limited).to eq(false) 181 | expect(info.remaining).to eq(limit) 182 | 183 | limiter.mark_overflowed('foo') 184 | 185 | limited, info = limiter.limit('foo', 1) 186 | 187 | expect(limited).to eq(true) 188 | expect(info.remaining).to eq(0) 189 | expect(info.retry_after).to eq(rate_period) # try again after the full rate period has elapsed 190 | end 191 | 192 | it 'marks a key with no previous data as being out of quota' do 193 | limit = 5 194 | rate_period = 1.0 # per second 195 | store = TestStore.new 196 | limiter = GCRA::RateLimiter.new(store, rate_period, limit) 197 | 198 | limiter.mark_overflowed('foo') 199 | 200 | limited, info = limiter.limit('foo', 1) 201 | 202 | expect(limited).to eq(true) 203 | expect(info.remaining).to eq(0) 204 | expect(info.retry_after).to eq(rate_period) # try again after the full rate period has elapsed 205 | end 206 | end 207 | 208 | class TestStore 209 | attr_accessor :now 210 | attr_accessor :fail_sets 211 | 212 | def initialize 213 | @now = 0 214 | @data = {} 215 | @fail_sets = false 216 | end 217 | 218 | def get_with_time(key) 219 | return @data[key], @now 220 | end 221 | 222 | def set_if_not_exists_with_ttl(key, value, ttl) 223 | return false if fail_sets 224 | 225 | if @data.has_key?(key) 226 | return false 227 | end 228 | 229 | @data[key] = value 230 | return true 231 | end 232 | 233 | def compare_and_set_with_ttl(key, old_value, new_value, ttl) 234 | return false if fail_sets 235 | 236 | if @data[key] != old_value 237 | return false 238 | end 239 | 240 | @data[key] = new_value 241 | return true 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /spec/lib/gcra/redis_store_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'redis' 3 | require 'digest/sha1' 4 | require_relative '../../../lib/gcra/rate_limiter' 5 | require_relative '../../../lib/gcra/redis_store' 6 | 7 | RSpec.describe GCRA::RedisStore do 8 | # Needs redis running on localhost:6379 (default port) 9 | let(:redis) { Redis.new } 10 | let(:key_prefix) { 'gcra-ruby-specs:' } 11 | let(:options) { {} } 12 | let(:store) { described_class.new(redis, key_prefix, options) } 13 | 14 | def cleanup_redis 15 | keys = redis.keys("#{key_prefix}*") 16 | unless keys.empty? 17 | redis.del(keys) 18 | end 19 | end 20 | 21 | before do 22 | begin 23 | redis.ping 24 | rescue Redis::CannotConnectError 25 | pending('Redis is not running on localhost:6379, skipping') 26 | end 27 | 28 | cleanup_redis 29 | end 30 | 31 | after do 32 | cleanup_redis 33 | end 34 | 35 | specify "canary: CAS_SHA is up to date" do 36 | actual_sha = Digest::SHA1.hexdigest(GCRA::RedisStore::CAS_SCRIPT) 37 | stored_sha = GCRA::RedisStore::CAS_SHA 38 | expect(actual_sha).to eq(stored_sha), 39 | "CAS_SCRIPT was updated without adjusting CAS_SHA! Please change CAS_SHA to '#{stored_sha}'" 40 | end 41 | 42 | describe '#get_with_time' do 43 | it 'with a value set, returns time and value' do 44 | redis.set('gcra-ruby-specs:foo', 1_485_422_362_766_819_000) 45 | 46 | value, time = store.get_with_time('foo') 47 | 48 | expect(value).to eq(1_485_422_362_766_819_000) 49 | expect(time).to be > 1_000_000_000_000_000_000 50 | expect(time).to be < 3_000_000_000_000_000_000 51 | end 52 | 53 | it 'with no value set, returns time and value' do 54 | value, time = store.get_with_time('foo') 55 | 56 | expect(value).to eq(nil) 57 | expect(time).to be > 1_000_000_000_000_000_000 58 | expect(time).to be < 3_000_000_000_000_000_000 59 | end 60 | end 61 | 62 | describe '#set_if_not_exists_with_ttl' do 63 | it 'with an existing key, returns false' do 64 | redis.set('gcra-ruby-specs:foo', 1_485_422_362_766_819_000) 65 | 66 | did_set = store.set_if_not_exists_with_ttl('foo', 2_000_000_000_000_000_000, 1) 67 | 68 | expect(did_set).to eq(false) 69 | end 70 | 71 | it 'with a readonly host and no readonly configured' do 72 | exception = Redis::ReadOnlyError.new 73 | 74 | expect(redis) 75 | .to receive(:set) 76 | .with('gcra-ruby-specs:foo', 2_000_000_000_000_000_000, nx: true, px: 1) 77 | .and_raise(exception) 78 | 79 | expect do 80 | store.set_if_not_exists_with_ttl('foo', 2_000_000_000_000_000_000, 1) 81 | end.to raise_error(exception) 82 | end 83 | 84 | context 'with reconnect on readonly configured' do 85 | let(:options) do 86 | { 87 | reconnect_on_readonly: true, 88 | } 89 | end 90 | 91 | it 'with a readonly host' do 92 | exception = Redis::ReadOnlyError.new 93 | 94 | expect(redis) 95 | .to receive(:close) 96 | .and_call_original 97 | 98 | expect(redis) 99 | .to receive(:set) 100 | .with('gcra-ruby-specs:foo', 2_000_000_000_000_000_000, nx: true, px: 1) 101 | .and_raise(exception) 102 | 103 | expect(redis) 104 | .to receive(:set) 105 | .with('gcra-ruby-specs:foo', 2_000_000_000_000_000_000, nx: true, px: 1) 106 | .and_call_original 107 | 108 | store.set_if_not_exists_with_ttl('foo', 2_000_000_000_000_000_000, 1) 109 | end 110 | end 111 | 112 | it 'with no existing key, returns true' do 113 | did_set = store.set_if_not_exists_with_ttl( 114 | 'foo', 3_000_000_000_000_000_000, 10 * 1_000_000_000 115 | ) 116 | 117 | expect(did_set).to eq(true) 118 | expect(redis.ttl('gcra-ruby-specs:foo')).to be > 8 119 | expect(redis.ttl('gcra-ruby-specs:foo')).to be <= 10 120 | end 121 | 122 | it 'with a very low ttl (less than 1ms)' do 123 | did_set = store.set_if_not_exists_with_ttl( 124 | 'foo', 3_000_000_000_000_000_000, 100 125 | ) 126 | 127 | expect(did_set).to eq(true) 128 | expect(redis.ttl('gcra-ruby-specs:foo')).to be <= 1 129 | end 130 | end 131 | 132 | describe '#compare_and_set_with_ttl' do 133 | it 'with no existing key, returns false' do 134 | swapped = store.compare_and_set_with_ttl( 135 | 'foo', 2_000_000_000_000_000_000, 3_000_000_000_000_000_000, 1 * 1_000_000_000 136 | ) 137 | 138 | expect(swapped).to eq(false) 139 | expect(redis.get('gcra-ruby-specs:foo')).to be_nil 140 | end 141 | 142 | it 'with an existing key and not matching old value, returns false' do 143 | redis.set('gcra-ruby-specs:foo', 1_485_422_362_766_819_000) 144 | 145 | swapped = store.compare_and_set_with_ttl( 146 | 'foo', 2_000_000_000_000_000_000, 3_000_000_000_000_000_000, 10 * 1_000_000_000 147 | ) 148 | 149 | expect(swapped).to eq(false) 150 | expect(redis.get('gcra-ruby-specs:foo')).to eq('1485422362766819000') 151 | end 152 | 153 | it 'with an existing key and matching old value, returns true' do 154 | redis.set('gcra-ruby-specs:foo', 2_000_000_000_000_000_000) 155 | 156 | swapped = store.compare_and_set_with_ttl( 157 | 'foo', 2_000_000_000_000_000_000, 3_000_000_000_000_000_000, 10 * 1_000_000_000 158 | ) 159 | 160 | expect(swapped).to eq(true) 161 | expect(redis.get('gcra-ruby-specs:foo')).to eq('3000000000000000000') 162 | expect(redis.ttl('gcra-ruby-specs:foo')).to be > 8 163 | expect(redis.ttl('gcra-ruby-specs:foo')).to be <= 10 164 | end 165 | 166 | it 'with an existing key and a very low ttl (less than 1ms)' do 167 | redis.set('gcra-ruby-specs:foo', 2_000_000_000_000_000_000) 168 | 169 | swapped = store.compare_and_set_with_ttl( 170 | 'foo', 2_000_000_000_000_000_000, 3_000_000_000_000_000_000, 100 171 | ) 172 | 173 | expect(swapped).to eq(true) 174 | expect(redis.ttl('gcra-ruby-specs:foo')).to be <= 1 175 | end 176 | 177 | it 'handles the script cache being purged (gracefully reloads script)' do 178 | redis.set('gcra-ruby-specs:foo', 2_000_000_000_000_000_000) 179 | 180 | swapped = store.compare_and_set_with_ttl( 181 | 'foo', 2_000_000_000_000_000_000, 3_000_000_000_000_000_000, 10 * 1_000_000_000 182 | ) 183 | 184 | expect(swapped).to eq(true) 185 | expect(redis.get('gcra-ruby-specs:foo')).to eq('3000000000000000000') 186 | expect(redis.ttl('gcra-ruby-specs:foo')).to be > 8 187 | expect(redis.ttl('gcra-ruby-specs:foo')).to be <= 10 188 | 189 | # purge the script cache, this will trigger an exception branch that reloads the script 190 | redis.script('flush') 191 | 192 | swapped = store.compare_and_set_with_ttl( 193 | 'foo', 3_000_000_000_000_000_000, 4_000_000_000_000_000_000, 10 * 1_000_000_000 194 | ) 195 | 196 | expect(swapped).to eq(true) 197 | expect(redis.get('gcra-ruby-specs:foo')).to eq('4000000000000000000') 198 | expect(redis.ttl('gcra-ruby-specs:foo')).to be > 8 199 | expect(redis.ttl('gcra-ruby-specs:foo')).to be <= 10 200 | end 201 | 202 | context 'with reconnect on readonly not configured' do 203 | it 'raises an error when the request is executed against a readonly host' do 204 | exception = Redis::ReadOnlyError.new 205 | 206 | expect(redis) 207 | .to receive(:evalsha) 208 | .with( 209 | GCRA::RedisStore::CAS_SHA, 210 | keys: ['gcra-ruby-specs:foo'], 211 | argv: [3000000000000000000, 4000000000000000000, 10000], 212 | ) 213 | .and_raise(exception) 214 | 215 | expect do 216 | store.compare_and_set_with_ttl( 217 | 'foo', 218 | 3_000_000_000_000_000_000, 219 | 4_000_000_000_000_000_000, 220 | 10 * 1_000_000_000, 221 | ) 222 | end.to raise_error(exception) 223 | end 224 | end 225 | 226 | context 'with reconnect on readonly configured' do 227 | let(:options) do 228 | { 229 | reconnect_on_readonly: true, 230 | } 231 | end 232 | 233 | it 'attempts a reconnect once and then executes evalsha again' do 234 | exception = Redis::ReadOnlyError.new 235 | 236 | expect(redis) 237 | .to receive(:close) 238 | .and_call_original 239 | 240 | expect(redis) 241 | .to receive(:evalsha) 242 | .with( 243 | GCRA::RedisStore::CAS_SHA, 244 | keys: ['gcra-ruby-specs:foo'], 245 | argv: [3000000000000000000, 4000000000000000000, 10000], 246 | ) 247 | .and_raise(exception) 248 | 249 | expect(redis) 250 | .to receive(:evalsha) 251 | .with( 252 | GCRA::RedisStore::CAS_SHA, 253 | keys: ['gcra-ruby-specs:foo'], 254 | argv: [3000000000000000000, 4000000000000000000, 10000], 255 | ) 256 | 257 | store.compare_and_set_with_ttl( 258 | 'foo', 259 | 3_000_000_000_000_000_000, 260 | 4_000_000_000_000_000_000, 261 | 10 * 1_000_000_000, 262 | ) 263 | end 264 | end 265 | 266 | end 267 | 268 | context 'functional test with RateLimiter' do 269 | let(:limiter) { GCRA::RateLimiter.new(store, 1, 2) } 270 | 271 | it 'allow and limits properly' do 272 | # Attempt too high quantity 273 | limit1, info1 = limiter.limit('foo', 4) 274 | 275 | aggregate_failures do 276 | expect(limit1).to be true 277 | expect(info1.limit).to eq(3) 278 | expect(info1.remaining).to eq(3) 279 | expect(info1.reset_after).to eq(0.0) 280 | expect(info1.retry_after).to be_nil 281 | end 282 | 283 | # Normal request 284 | limit1, info1 = limiter.limit('foo', 1) 285 | 286 | aggregate_failures do 287 | expect(limit1).to be false 288 | expect(info1.limit).to eq(3) 289 | expect(info1.remaining).to eq(2) 290 | expect(info1.reset_after).to eq(1.0) 291 | expect(info1.retry_after).to be_nil 292 | end 293 | 294 | # Normal request, fills up rest of bucket 295 | limit1, info1 = limiter.limit('foo', 2) 296 | 297 | aggregate_failures do 298 | expect(limit1).to be false 299 | expect(info1.limit).to eq(3) 300 | expect(info1.remaining).to eq(0) 301 | expect(info1.reset_after).to be < 3.0 302 | expect(info1.reset_after).to be > 2.5 303 | expect(info1.retry_after).to be_nil 304 | end 305 | 306 | # Normal request, exceeds limit 307 | limit1, info1 = limiter.limit('foo', 1) 308 | 309 | aggregate_failures do 310 | expect(limit1).to be true 311 | expect(info1.limit).to eq(3) 312 | expect(info1.remaining).to eq(0) 313 | expect(info1.reset_after).to be < 3.0 314 | expect(info1.reset_after).to be > 2.0 315 | expect(info1.retry_after).to be < 1.0 316 | expect(info1.retry_after).to be > 0.5 317 | end 318 | 319 | # Allows a normal request after 1 second waiting 320 | sleep(1) 321 | limit1, info1 = limiter.limit('foo', 1) 322 | 323 | aggregate_failures do 324 | expect(limit1).to be false 325 | expect(info1.limit).to eq(3) 326 | expect(info1.remaining).to eq(0) 327 | expect(info1.reset_after).to be < 3.0 328 | expect(info1.reset_after).to be > 2.0 329 | expect(info1.retry_after).to be_nil 330 | end 331 | end 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | RSpec.configure do |config| 20 | # rspec-expectations config goes here. You can use an alternate 21 | # assertion/expectation library such as wrong or the stdlib/minitest 22 | # assertions if you prefer. 23 | config.expect_with :rspec do |expectations| 24 | # This option will default to `true` in RSpec 4. It makes the `description` 25 | # and `failure_message` of custom matchers include text for helper methods 26 | # defined using `chain`, e.g.: 27 | # be_bigger_than(2).and_smaller_than(4).description 28 | # # => "be bigger than 2 and smaller than 4" 29 | # ...rather than: 30 | # # => "be bigger than 2" 31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 32 | end 33 | 34 | # rspec-mocks config goes here. You can use an alternate test double 35 | # library (such as bogus or mocha) by changing the `mock_with` option here. 36 | config.mock_with :rspec do |mocks| 37 | # Prevents you from mocking or stubbing a method that does not exist on 38 | # a real object. This is generally recommended, and will default to 39 | # `true` in RSpec 4. 40 | mocks.verify_partial_doubles = true 41 | end 42 | 43 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 44 | # have no way to turn it off -- the option exists only for backwards 45 | # compatibility in RSpec 3). It causes shared context metadata to be 46 | # inherited by the metadata hash of host groups and examples, rather than 47 | # triggering implicit auto-inclusion in groups with matching metadata. 48 | config.shared_context_metadata_behavior = :apply_to_host_groups 49 | 50 | # The settings below are suggested to provide a good initial experience 51 | # with RSpec, but feel free to customize to your heart's content. 52 | =begin 53 | # This allows you to limit a spec run to individual examples or groups 54 | # you care about by tagging them with `:focus` metadata. When nothing 55 | # is tagged with `:focus`, all examples get run. RSpec also provides 56 | # aliases for `it`, `describe`, and `context` that include `:focus` 57 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 58 | config.filter_run_when_matching :focus 59 | 60 | # Allows RSpec to persist some state between runs in order to support 61 | # the `--only-failures` and `--next-failure` CLI options. We recommend 62 | # you configure your source control system to ignore this file. 63 | config.example_status_persistence_file_path = "spec/examples.txt" 64 | 65 | # Limits the available syntax to the non-monkey patched syntax that is 66 | # recommended. For more details, see: 67 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 68 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 69 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 70 | config.disable_monkey_patching! 71 | 72 | # This setting enables warnings. It's recommended, but in some cases may 73 | # be too noisy due to issues in dependencies. 74 | config.warnings = true 75 | 76 | # Many RSpec users commonly either run the entire suite or an individual 77 | # file, and it's useful to allow more verbose output when running an 78 | # individual spec file. 79 | if config.files_to_run.one? 80 | # Use the documentation formatter for detailed output, 81 | # unless a formatter has already been configured 82 | # (e.g. via a command-line flag). 83 | config.default_formatter = 'doc' 84 | end 85 | 86 | # Print the 10 slowest examples and example groups at the 87 | # end of the spec run, to help surface which specs are running 88 | # particularly slow. 89 | config.profile_examples = 10 90 | 91 | # Run specs in random order to surface order dependencies. If you find an 92 | # order dependency and want to debug it, you can fix the order by providing 93 | # the seed, which is printed after each run. 94 | # --seed 1234 95 | config.order = :random 96 | 97 | # Seed global randomization in this process using the `--seed` CLI option. 98 | # Setting this allows you to use `--seed` to deterministically reproduce 99 | # test failures related to randomization by passing the same `--seed` value 100 | # as the one that triggered the failure. 101 | Kernel.srand config.seed 102 | =end 103 | end 104 | --------------------------------------------------------------------------------