Skip to content

Commit

Permalink
Action based rate limiting.
Browse files Browse the repository at this point in the history
Include the module and write a rate_limit method to define the limit.
The rate_limit_key method can be overridden if you want differeny key
logic.

Ref luckyframework#1865
  • Loading branch information
russ committed Oct 6, 2024
1 parent 4cb2108 commit a4bce2c
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 0 deletions.
43 changes: 43 additions & 0 deletions spec/lucky/rate_limit_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require "../spec_helper"

include ContextHelper

class RateLimitRoutes::Index < TestAction
include Lucky::RateLimit

get "/rate_limit" do
plain_text "hello"
end

private def rate_limit : NamedTuple(to: Int32, within: Time::Span)
{to: 1, within: 1.minute}
end
end

describe Lucky::RateLimit do
describe "RateLimit" do
it "when request count is less than the rate limit" do
headers = HTTP::Headers.new
headers["X_FORWARDED_FOR"] = "127.0.0.1"
request = HTTP::Request.new("GET", "/rate_limit", body: "", headers: headers)
context = build_context(request)

route = RateLimitRoutes::Index.new(context, params).call
route.context.response.status.should eq(HTTP::Status::OK)
end

it "when request count is over the rate limit" do
headers = HTTP::Headers.new
headers["X_FORWARDED_FOR"] = "127.0.0.1"
request = HTTP::Request.new("GET", "/rate_limit", body: "", headers: headers)
context = build_context(request)

10.times do
RateLimitRoutes::Index.new(context, params).call
end

route = RateLimitRoutes::Index.new(context, params).call
route.context.response.status.should eq(HTTP::Status::TOO_MANY_REQUESTS)
end
end
end
4 changes: 4 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ Lucky::ForceSSLHandler.configure do |settings|
settings.enabled = true
end

LuckyCache.configure do |settings|
settings.storage = LuckyCache::MemoryStore.new
end

Habitat.raise_if_missing_settings!
41 changes: 41 additions & 0 deletions src/lucky/rate_limit.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module Lucky::RateLimit
macro included
before enforce_rate_limit
end

abstract def rate_limit : NamedTuple(to: Int32, within: Time::Span)

private def enforce_rate_limit
cache = LuckyCache.settings.storage
count = cache.fetch(rate_limit_key, as: Int32, expires_in: rate_limit["within"]) { 0 }
cache.write(rate_limit_key, expires_in: rate_limit["within"]) { count + 1 }

if count > rate_limit["to"]
context.response.status = HTTP::Status::TOO_MANY_REQUESTS
context.response.headers["Retry-After"] = rate_limit["within"].to_s
plain_text("Rate limit exceeded")
else
continue
end
end

private def rate_limit_key : String
klass = self.class.to_s.downcase.gsub("::", ":")
"ratelimit:#{klass}:#{rate_limit_identifier}"
end

private def rate_limit_identifier : Socket::Address | Nil
request = context.request

if x_forwarded = request.headers["X_FORWARDED_FOR"]?.try(&.split(',').first?).presence
begin
Socket::IPAddress.new(x_forwarded, 0)
rescue Socket::Error
# if the x_forwarded is not a valid ip address we fallback to request.remote_address
request.remote_address
end
else
request.remote_address
end
end
end

0 comments on commit a4bce2c

Please sign in to comment.