Skip to content

Commit bc3bd38

Browse files
committed
Initial implementation
Impliments a leaky bucket rate limiter, that unlike prorate continues to to count requests against the limit even when the rate limiter is in the blocking state. This means that the client has to slow down, or they will remain blocked indefinately. Optionally a penalty can be added, that adds additonal tokens to the bucket at the point that the limit is breached, to futher ensure that the block lasts longer for clients that are only marginly breaching the rate limit.
1 parent d17e46b commit bc3bd38

File tree

8 files changed

+202
-3
lines changed

8 files changed

+202
-3
lines changed

.github/workflows/main.yml

+13-1
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,24 @@ jobs:
1616
ruby:
1717
- '3.3.1'
1818

19+
services:
20+
redis:
21+
image: redis:7.2.5
22+
options: >-
23+
--health-cmd "redis-cli ping"
24+
--health-interval 10s
25+
--health-timeout 5s
26+
--health-retries 5
27+
ports:
28+
- 6379:6379
1929
steps:
20-
- uses: actions/checkout@v3
30+
- uses: actions/checkout@v4
2131
- name: Set up Ruby
2232
uses: ruby/setup-ruby@v1
2333
with:
2434
ruby-version: ${{ matrix.ruby }}
2535
bundler-cache: true
2636
- name: Run the default task
2737
run: bundle exec rake
38+
env:
39+
MILLRACE_REDIS_URL: redis://localhost:6379

Gemfile

+3
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ gem "rubocop", "~> 1.21"
1111
gem "rubocop-performance"
1212
gem "rubocop-rails"
1313
gem "rubocop-rspec"
14+
15+
gem "hiredis-client"
16+
gem "redis"

Gemfile.lock

+11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ PATH
22
remote: .
33
specs:
44
millrace (0.1.0)
5+
prorate
56

67
GEM
78
remote: https://rubygems.org/
@@ -23,6 +24,8 @@ GEM
2324
connection_pool (2.4.1)
2425
diff-lcs (1.5.1)
2526
drb (2.2.1)
27+
hiredis-client (0.22.2)
28+
redis-client (= 0.22.2)
2629
i18n (1.14.5)
2730
concurrent-ruby (~> 1.0)
2831
json (2.7.2)
@@ -33,10 +36,16 @@ GEM
3336
parser (3.3.1.0)
3437
ast (~> 2.4.1)
3538
racc
39+
prorate (0.7.3)
40+
redis (>= 2)
3641
racc (1.7.3)
3742
rack (3.0.11)
3843
rainbow (3.1.1)
3944
rake (13.2.1)
45+
redis (5.2.0)
46+
redis-client (>= 0.22.0)
47+
redis-client (0.22.2)
48+
connection_pool
4049
regexp_parser (2.9.0)
4150
rexml (3.2.8)
4251
strscan (>= 3.0.9)
@@ -96,8 +105,10 @@ PLATFORMS
96105
ruby
97106

98107
DEPENDENCIES
108+
hiredis-client
99109
millrace!
100110
rake (~> 13.0)
111+
redis
101112
rspec (~> 3.0)
102113
rubocop (~> 1.21)
103114
rubocop-performance

lib/millrace.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# frozen_string_literal: true
22

33
require_relative "millrace/version"
4+
require_relative "millrace/rate_limited"
5+
require_relative "millrace/rate_limit"
46

57
module Millrace
6-
class Error < StandardError; end
7-
# Your code goes here...
88
end

lib/millrace/rate_limit.rb

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
require "digest"
2+
require "prorate"
3+
4+
module Millrace
5+
class RateLimit
6+
def initialize(name:, rate:, window:, penalty: 0, redis_config: nil)
7+
@name = name
8+
@rate = rate
9+
@window = window
10+
@penalty = penalty
11+
@redis_config = redis_config
12+
end
13+
14+
attr_reader :name, :rate, :window
15+
16+
def before(controller)
17+
bucket = get_bucket(controller.request.remote_ip)
18+
level = record_request(bucket)
19+
20+
return unless level > threshold
21+
22+
if level - 1 < threshold
23+
level = bucket.fillup(penalty).level
24+
end
25+
26+
raise RateLimited.new(limit_name: name, retry_after: retry_after(level))
27+
end
28+
29+
private
30+
31+
def retry_after(level)
32+
((level - threshold) / rate).to_i
33+
end
34+
35+
def record_request(bucket)
36+
bucket.fillup(1).level
37+
end
38+
39+
def get_bucket(ip)
40+
Prorate::LeakyBucket.new(
41+
redis: redis,
42+
redis_key_prefix: key(ip),
43+
leak_rate: rate,
44+
bucket_capacity: capacity,
45+
)
46+
end
47+
48+
def key(ip)
49+
"millrace.#{name}.#{Digest::SHA1.hexdigest(ip)}"
50+
end
51+
52+
def capacity
53+
(threshold * 2) + penalty
54+
end
55+
56+
def threshold
57+
window * rate
58+
end
59+
60+
def penalty
61+
@penalty * rate
62+
end
63+
64+
def redis_config
65+
@redis_config || { url: ENV.fetch("MILLRACE_REDIS_URL", nil) }.compact
66+
end
67+
68+
def redis
69+
Thread.current["millrace_#{name}_redis"] ||= Redis.new(redis_config)
70+
end
71+
end
72+
end

lib/millrace/rate_limited.rb

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module Millrace
2+
class RateLimited < StandardError
3+
def initialize(limit_name:, retry_after:)
4+
@limit_name = limit_name
5+
@retry_after = retry_after
6+
end
7+
8+
attr_reader :limit_name, :retry_after
9+
end
10+
end

millrace.gemspec

+2
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,6 @@ Gem::Specification.new do |spec|
3838
# For more information and examples about making a new gem, check out our
3939
# guide at: https://bundler.io/guides/creating_gem.html
4040
spec.metadata["rubygems_mfa_required"] = "true"
41+
42+
spec.add_dependency "prorate"
4143
end

spec/rate_limit_spec.rb

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Millrace::RateLimit do
4+
let(:subject) do
5+
described_class.new(
6+
name: "test",
7+
rate: 10,
8+
window: 2,
9+
penalty: penalty,
10+
)
11+
end
12+
13+
let(:penalty) { 1 }
14+
15+
let(:controller) do
16+
double(:controller, request: double(:request, remote_ip: to_s))
17+
end
18+
19+
describe "#before" do
20+
it "rate limits" do
21+
# Fill the bucket
22+
20.times { subject.before(controller) }
23+
24+
# hit the threshold and get a penalty
25+
expect { subject.before(controller) }.to raise_error Millrace::RateLimited
26+
27+
sleep 1
28+
# Still blocked for the penalty duration
29+
expect { subject.before(controller) }.to raise_error Millrace::RateLimited
30+
31+
# Not blocked after the penalty duration is over
32+
sleep 1
33+
subject.before(controller)
34+
end
35+
36+
it "returns an exeption with the correct name" do
37+
# Fill the bucket
38+
20.times { subject.before(controller) }
39+
40+
# hit the threshold and get an error
41+
expect { subject.before(controller) }.to raise_error do |exception|
42+
expect(exception.limit_name).to eq "test"
43+
end
44+
end
45+
46+
it "returns an exeption with the correct retry time" do
47+
# Fill the bucket
48+
20.times { subject.before(controller) }
49+
50+
# hit the threshold and get an error
51+
expect { subject.before(controller) }.to raise_error do |exception|
52+
expect(exception.retry_after).to eq 1
53+
end
54+
end
55+
56+
context "a longer penalty" do
57+
let(:penalty) { 10 }
58+
59+
it "returns an exeption with the correct retry time" do
60+
# Fill the bucket
61+
20.times { subject.before(controller) }
62+
63+
# hit the threshold and get an error
64+
expect { subject.before(controller) }.to raise_error do |exception|
65+
expect(exception.retry_after).to eq 10
66+
end
67+
end
68+
end
69+
70+
context "additional requests" do
71+
let(:penalty) { 0 }
72+
73+
it "returns an exeption with the correct retry time" do
74+
# Fill the bucket
75+
40.times do
76+
subject.before(controller)
77+
# Keep making requests even though we are rate limited
78+
rescue Millrace::RateLimited
79+
nil
80+
end
81+
82+
# hit the threshold and get an error
83+
expect { subject.before(controller) }.to raise_error do |exception|
84+
expect(exception.retry_after).to eq 2
85+
end
86+
end
87+
end
88+
end
89+
end

0 commit comments

Comments
 (0)