├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── redis-gcra.rb └── redis-gcra │ ├── result.rb │ └── version.rb ├── redis-gcra.gemspec ├── spec ├── redis_gcra_spec.rb └── spec_helper.rb └── vendor ├── inspect_gcra_ratelimit.lua └── perform_gcra_ratelimit.lua /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.6 4 | - 2.5 5 | - 2.4 6 | - 2.3 7 | - 2.2 8 | before_install: 9 | - wget -qO- 'https://github.com/antirez/redis/archive/3.2.10.tar.gz' | tar -xzC /tmp 10 | - make -j -C /tmp/redis-3.2.10 INSTALL_BIN=/tmp/redis-3.2.10 install 11 | - cd /tmp/redis-3.2.10/src && ./redis-server > /dev/null 2>&1 & 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "pry", "~> 0.10" 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.5" 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Pavel Pravosud 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RedisGCRA 2 | [![Build Status](https://travis-ci.org/rwz/redis-gcra.svg?branch=master)](https://travis-ci.org/rwz/redis-gcra) 3 | [![Gem Version](https://img.shields.io/gem/v/redis-gcra.svg)](https://rubygems.org/gems/redis-gcra) 4 | 5 | This gem is an implementation of [GCRA][gcra] for rate limiting based on Redis. 6 | The code requires Redis version 3.2 or newer since it relies on 7 | [`replicate_commands`][redis-replicate-commands] feature. 8 | 9 | [gcra]: https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm 10 | [redis-replicate-commands]: https://redis.io/commands/eval#replicating-commands-instead-of-scripts 11 | 12 | ## Installation 13 | 14 | ```ruby 15 | gem "redis-gcra" 16 | ``` 17 | 18 | And then execute: 19 | 20 | $ bundle 21 | 22 | Or install it yourself as: 23 | 24 | $ gem install redis-gcra 25 | 26 | ## Usage 27 | 28 | In order to perform rate limiting, you need to call the `limit` method. 29 | 30 | In this example the rate limit bucket has 1000 tokens in it and recovers at 31 | speed of 100 tokens per minute. 32 | 33 | ```ruby 34 | redis = Redis.new 35 | 36 | result = RedisGCRA.limit( 37 | redis: redis, 38 | key: "overall-account/bob@example.com", 39 | burst: 1000, 40 | rate: 100, 41 | period: 60, # seconds 42 | cost: 2 43 | ) 44 | 45 | result.limited? # => false - request should not be limited 46 | result.remaining # => 998 - remaining number of requests until limited 47 | result.retry_after # => nil - can retry without delay 48 | result.reset_after # => ~0.6 - in 0.6 seconds rate limiter will completely reset 49 | 50 | # call limit 499 more times in rapid succession and you get: 51 | 52 | result.limited? # => true - request should be limited 53 | result.remaining # => 0 - no requests can be made at this point 54 | result.retry_after # => ~1.2 - can retry in 1.2 seconds 55 | result.reset_after # => ~600 - in 600 seconds rate limiter will completely reset 56 | ``` 57 | 58 | The implementation utilizes single key in Redis that matches the key you pass 59 | to the `limit` method. If you need to reset rate limiter for particular key, 60 | just delete the key from Redis: 61 | 62 | ```ruby 63 | # Let's imagine `overall-account/bob@example.com` is limited. 64 | # This will effectively reset limit for the key: 65 | redis.del "overall-account/bob@example.com" 66 | ``` 67 | 68 | You call also retrieve the current state of rate limiter for particular key 69 | without actually modifying the state. In order to do that, use the `peek` 70 | method: 71 | 72 | ```ruby 73 | result = RedisGCRA.peek( 74 | redis: redis, 75 | key: "overall-account/bob@example.com", 76 | burst: 1000, 77 | rate: 100, 78 | period: 60 # seconds 79 | ) 80 | 81 | result.limited? # => true - current state is limited 82 | result.remaining # => 0 - no requests can be made 83 | result.retry_after # => ~0.6 - in 0.6 seconds remaining will become 1 84 | result.reset_after # => ~600 - in 600 seconds rate limiter will completely reset 85 | ``` 86 | 87 | ## Inspiration 88 | 89 | This code was inspired by this great [blog post][blog-post] by [Brandur 90 | Leach][brandur] and his amazing work on [throttled Go package][throttled]. 91 | 92 | [blog-post]: https://brandur.org/rate-limiting 93 | [brandur]: https://github.com/brandur 94 | [throttled]: https://github.com/throttled/throttled 95 | 96 | ## License 97 | 98 | The gem is available as open source under the terms of the [MIT License][mit]. 99 | 100 | [mit]: http://opensource.org/licenses/MIT 101 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /lib/redis-gcra.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | require "thread" 3 | 4 | module RedisGCRA 5 | extend self 6 | 7 | autoload :Result, "redis-gcra/result" 8 | 9 | def limit(redis:, key:, burst:, rate:, period:, cost: 1) 10 | call(redis, :perform_gcra_ratelimit, key, burst, rate, period, cost) 11 | end 12 | 13 | def peek(redis:, key:, burst:, rate:, period:) 14 | call(redis, :inspect_gcra_ratelimit, key, burst, rate, period) 15 | end 16 | 17 | private 18 | 19 | def call(redis, script_name, key, *argv) 20 | res = call_script(redis, script_name, keys: [key], argv: argv) 21 | 22 | Result.new( 23 | limited: res[0] == 1, 24 | remaining: res[1], 25 | retry_after: parse_float_string(res[2]), 26 | reset_after: parse_float_string(res[3]) 27 | ) 28 | end 29 | 30 | def parse_float_string(value) 31 | value == "-1" ? nil : value.to_f 32 | end 33 | 34 | def call_script(redis, script_name, *args) 35 | script_sha = mutex.synchronize { get_cached_sha(redis, script_name) } 36 | redis.evalsha(script_sha, *args) 37 | end 38 | 39 | def redis_cache 40 | @redis_script_cache ||= {} 41 | end 42 | 43 | def mutex 44 | @mutex ||= Mutex.new 45 | end 46 | 47 | def get_cached_sha(redis, script_name) 48 | cache_key = "#{redis.id}/#{script_name}" 49 | redis_cache[cache_key] ||= load_script(redis, script_name) 50 | end 51 | 52 | def load_script(redis, script_name) 53 | script_path = File.expand_path("../../vendor/#{script_name}.lua", __FILE__) 54 | script = File.read(script_path) 55 | script_sha = Digest::SHA1.hexdigest(script) 56 | return script_sha if redis.script(:exists, script_sha) 57 | redis.script(:load, script) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/redis-gcra/result.rb: -------------------------------------------------------------------------------- 1 | module RedisGCRA 2 | class Result 3 | attr_reader :remaining, :reset_after, :retry_after 4 | 5 | def initialize(limited:, remaining:, reset_after:, retry_after:) 6 | @limited = limited 7 | @remaining = remaining 8 | @reset_after = reset_after 9 | @retry_after = retry_after 10 | end 11 | 12 | def limited? 13 | !!@limited 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/redis-gcra/version.rb: -------------------------------------------------------------------------------- 1 | module RedisGCRA 2 | VERSION = "0.5.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /redis-gcra.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/redis-gcra/version", __FILE__) 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "redis-gcra" 5 | spec.version = RedisGCRA::VERSION 6 | spec.authors = ["Pavel Pravosud"] 7 | spec.email = ["pavel@pravosud.com"] 8 | spec.summary = "Rate limiting based on Generic Cell Rate Algorithm" 9 | spec.homepage = "https://github.com/rwz/redis-gcra" 10 | spec.license = "MIT" 11 | spec.files = Dir["LICENSE.txt", "README.md", "lib/**/**", "vendor/**/**"] 12 | spec.require_path = "lib" 13 | 14 | spec.add_dependency "redis", ">= 3.3", "< 5" 15 | end 16 | -------------------------------------------------------------------------------- /spec/redis_gcra_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe RedisGCRA do 4 | let(:redis) { RedisConnection } 5 | 6 | context "#limit" do 7 | def call(key: "foo", cost: 1, burst: 300, rate: 60, period: 60) 8 | described_class.limit( 9 | redis: redis, 10 | key: key, 11 | burst: burst, 12 | rate: rate, 13 | period: period, 14 | cost: cost 15 | ) 16 | end 17 | 18 | it "calculates rate limit result" do 19 | 100.times { call } 20 | result = call 21 | expect(result).to_not be_limited 22 | expect(result.remaining).to eq(199) 23 | expect(result.retry_after).to be_nil 24 | expect(result.reset_after).to be_within(0.1).of(101.0) 25 | end 26 | 27 | it "rate limits different keys independently" do 28 | 100.times { call cost: 10 } 29 | result = call(cost: 2, key: "bar") 30 | expect(result).to_not be_limited 31 | expect(result.remaining).to eq(298) 32 | expect(result.retry_after).to be_nil 33 | expect(result.reset_after).to be_within(0.1).of(2.0) 34 | end 35 | 36 | it "calculates rate limit with non-1 cost correctly" do 37 | 100.times { call(cost: 2) } 38 | result = call(cost: 2) 39 | expect(result).to_not be_limited 40 | expect(result.remaining).to eq(98) 41 | expect(result.retry_after).to be_nil 42 | expect(result.reset_after).to be_within(0.1).of(202.0) 43 | end 44 | 45 | it "limits once bucket has been depleted" do 46 | 300.times { call } 47 | 48 | 10.times do 49 | result = call 50 | expect(result).to be_limited 51 | expect(result.remaining).to be(0) 52 | expect(result.retry_after).to be_within(0.5).of(1.0) 53 | expect(result.reset_after).to be_within(0.5).of(300.0) 54 | end 55 | end 56 | 57 | it "recovers after certain time" do 58 | 300.times { call } 59 | limited_result = call 60 | expect(limited_result).to be_limited 61 | sleep limited_result.retry_after 62 | passed_result = call 63 | expect(passed_result).to_not be_limited 64 | end 65 | 66 | it "should pass when cost is bigger than the remaining" do 67 | call cost: 299 68 | result = call(cost: 2) 69 | expect(result).to be_limited 70 | expect(result.remaining).to eq(0) 71 | expect(result.retry_after).to be_within(0.1).of(1.0) 72 | end 73 | 74 | test_cases = [ 75 | { burst: 4500, rate: 75, period: 60, cost: 1, repeat: 1, expected_remaining: 4499 }, 76 | { burst: 4500, rate: 75, period: 60, cost: 1, repeat: 2, expected_remaining: 4498 }, 77 | { burst: 4500, rate: 75, period: 60, cost: 2, repeat: 1, expected_remaining: 4498 }, 78 | { burst: 1000, rate: 100, period: 60, cost: 200, repeat: 1, expected_remaining: 800 }, 79 | { burst: 1000, rate: 100, period: 60, cost: 200, repeat: 4, expected_remaining: 200 }, 80 | { burst: 1000, rate: 100, period: 60, cost: 200, repeat: 5, expected_remaining: 0 }, 81 | { burst: 1000, rate: 100, period: 60, cost: 1, repeat: 137, expected_remaining: 863 }, 82 | { burst: 1000, rate: 100, period: 60, cost: 1001, repeat: 1, expected_remaining: 0 } 83 | ] 84 | 85 | test_cases.each_with_index do |test_case, index| 86 | it "calculates test case ##{index+1} correctly" do 87 | result = test_case[:repeat].times.map do 88 | call( 89 | burst: test_case[:burst], 90 | rate: test_case[:rate], 91 | period: test_case[:period], 92 | cost: test_case[:cost] 93 | ) 94 | end.last 95 | 96 | expect(result.remaining).to eq(test_case[:expected_remaining]) 97 | end 98 | end 99 | end 100 | 101 | context "#peek" do 102 | let(:default_config) { { redis: redis, key: "foo", burst: 300, rate: 60, period: 60 } } 103 | 104 | def peek(**options) 105 | described_class.peek(**default_config.merge(options)) 106 | end 107 | 108 | def limit(cost: 1, **options) 109 | described_class.limit(cost: cost, **default_config.merge(options)) 110 | end 111 | 112 | it "returns initial state without modifying it" do 113 | result = peek 114 | 115 | expect(result).to_not be_limited 116 | expect(result.remaining).to eq(300) 117 | expect(result.retry_after).to be_nil 118 | expect(result.reset_after).to be_nil 119 | end 120 | 121 | it "describeds partially drained state correctly" do 122 | limit cost: 10 123 | 124 | result = peek 125 | 126 | expect(result).to_not be_limited 127 | expect(result.remaining).to eq(290) 128 | expect(result.retry_after).to be_nil 129 | expect(result.reset_after).to be_within(0.1).of(10.0) 130 | end 131 | 132 | it "describes fully drained state correctly" do 133 | limit cost: 300 134 | 135 | # wait a bit, but not enough for the bucket to recover from 0 to 1 136 | sleep 0.3 137 | 138 | result = peek 139 | 140 | expect(result.remaining).to eq(0) 141 | expect(result.retry_after).to be_within(0.1).of(0.7) # 1 second - 0.3 142 | expect(result.reset_after).to be_within(0.1).of(299.7) # 300 seconds - 0.3 143 | expect(result).to be_limited 144 | end 145 | end 146 | 147 | context "loading scripts" do 148 | let(:options) { { redis: redis, key: "foo", burst: 300, rate: 60, period: 60 } } 149 | 150 | before do 151 | described_class.instance_eval { redis_cache.clear } 152 | redis.script :flush 153 | end 154 | 155 | def peek 156 | described_class.peek(**options) 157 | end 158 | 159 | def limit 160 | described_class.limit(**options) 161 | end 162 | 163 | it "loads scripts" do 164 | expect { limit }.to_not raise_error 165 | expect { peek }.to_not raise_error 166 | 167 | shas = described_class.instance_eval { redis_cache } 168 | 169 | expect(shas.keys).to contain_exactly(/inspect_gcra_ratelimit\z/, /perform_gcra_ratelimit\z/) 170 | 171 | shas.each do |_, sha| 172 | expect(redis.script(:exists, sha)).to be(true) 173 | end 174 | end 175 | 176 | it "only loads when script is not loaded already" do 177 | expect(redis).to receive(:script).with(:exists, /\A\h{40}\z/).twice.and_return(true) 178 | expect(redis).to_not receive(:script).with(:load, anything) 179 | 180 | expect { limit }.to raise_error(Redis::CommandError, /NOSCRIPT/) 181 | expect { peek }.to raise_error(Redis::CommandError, /NOSCRIPT/) 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "redis" 2 | require "redis-gcra" 3 | 4 | RedisConnection = Redis.new 5 | 6 | RSpec.configure do |config| 7 | config.before(:example) { RedisConnection.flushall } 8 | end 9 | -------------------------------------------------------------------------------- /vendor/inspect_gcra_ratelimit.lua: -------------------------------------------------------------------------------- 1 | local rate_limit_key = KEYS[1] 2 | local burst = ARGV[1] 3 | local rate = ARGV[2] 4 | local period = ARGV[3] 5 | 6 | local emission_interval = period / rate 7 | local burst_offset = emission_interval * burst 8 | local now = redis.call("TIME") 9 | 10 | -- redis returns time as an array containing two integers: seconds of the epoch 11 | -- time (10 digits) and microseconds (6 digits). for convenience we need to 12 | -- convert them to a floating point number. the resulting number is 16 digits, 13 | -- bordering on the limits of a 64-bit double-precision floating point number. 14 | -- adjust the epoch to be relative to Jan 1, 2017 00:00:00 GMT to avoid floating 15 | -- point problems. this approach is good until "now" is 2,483,228,799 (Wed, 09 16 | -- Sep 2048 01:46:39 GMT), when the adjusted value is 16 digits. 17 | local jan_1_2017 = 1483228800 18 | now = (now[1] - jan_1_2017) + (now[2] / 1000000) 19 | 20 | local tat = redis.call("GET", rate_limit_key) 21 | 22 | if not tat then 23 | tat = now 24 | else 25 | tat = tonumber(tat) 26 | end 27 | 28 | local allow_at = math.max(tat, now) - burst_offset 29 | local diff = now - allow_at 30 | 31 | local remaining = math.floor(diff / emission_interval + 0.5) -- poor man's round 32 | 33 | local reset_after = tat - now 34 | if reset_after == 0 then 35 | reset_after = -1 36 | end 37 | 38 | local limited 39 | local retry_after 40 | 41 | if remaining < 1 then 42 | remaining = 0 43 | limited = 1 44 | retry_after = emission_interval - diff 45 | else 46 | limited = 0 47 | retry_after = -1 48 | end 49 | 50 | return {limited, remaining, tostring(retry_after), tostring(reset_after)} 51 | -------------------------------------------------------------------------------- /vendor/perform_gcra_ratelimit.lua: -------------------------------------------------------------------------------- 1 | -- this script has side-effects, so it requires replicate commands mode 2 | redis.replicate_commands() 3 | 4 | local rate_limit_key = KEYS[1] 5 | local burst = ARGV[1] 6 | local rate = ARGV[2] 7 | local period = ARGV[3] 8 | local cost = ARGV[4] 9 | 10 | local emission_interval = period / rate 11 | local increment = emission_interval * cost 12 | local burst_offset = emission_interval * burst 13 | local now = redis.call("TIME") 14 | 15 | -- redis returns time as an array containing two integers: seconds of the epoch 16 | -- time (10 digits) and microseconds (6 digits). for convenience we need to 17 | -- convert them to a floating point number. the resulting number is 16 digits, 18 | -- bordering on the limits of a 64-bit double-precision floating point number. 19 | -- adjust the epoch to be relative to Jan 1, 2017 00:00:00 GMT to avoid floating 20 | -- point problems. this approach is good until "now" is 2,483,228,799 (Wed, 09 21 | -- Sep 2048 01:46:39 GMT), when the adjusted value is 16 digits. 22 | local jan_1_2017 = 1483228800 23 | now = (now[1] - jan_1_2017) + (now[2] / 1000000) 24 | 25 | local tat = redis.call("GET", rate_limit_key) 26 | 27 | if not tat then 28 | tat = now 29 | else 30 | tat = tonumber(tat) 31 | end 32 | 33 | local new_tat = math.max(tat, now) + increment 34 | 35 | local allow_at = new_tat - burst_offset 36 | local diff = now - allow_at 37 | 38 | local limited 39 | local retry_after 40 | local reset_after 41 | 42 | local remaining = math.floor(diff / emission_interval + 0.5) -- poor man's round 43 | 44 | if remaining < 0 then 45 | limited = 1 46 | remaining = 0 47 | reset_after = tat - now 48 | retry_after = diff * -1 49 | else 50 | limited = 0 51 | reset_after = new_tat - now 52 | redis.call("SET", rate_limit_key, new_tat, "EX", math.ceil(reset_after)) 53 | retry_after = -1 54 | end 55 | 56 | return {limited, remaining, tostring(retry_after), tostring(reset_after)} 57 | --------------------------------------------------------------------------------