-
-
Notifications
You must be signed in to change notification settings - Fork 158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Action based rate limiting. #1917
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -38,7 +38,7 @@ | |||||||||||
|
||||||||||||
Avram.configure do |settings| | ||||||||||||
settings.database_to_migrate = UnusedDatabase | ||||||||||||
end | ||||||||||||
Check failure on line 41 in spec/spec_helper.cr GitHub Actions / specs (shard.yml, 1.10.0, false)
Check failure on line 41 in spec/spec_helper.cr GitHub Actions / specs (shard.yml, latest, false)
Check failure on line 41 in spec/spec_helper.cr GitHub Actions / specs (shard.edge.yml, latest, true)
|
||||||||||||
|
||||||||||||
Lucky::ErrorHandler.configure do |settings| | ||||||||||||
settings.show_debug_output = false | ||||||||||||
|
@@ -48,4 +48,8 @@ | |||||||||||
settings.enabled = true | ||||||||||||
end | ||||||||||||
|
||||||||||||
LuckyCache.configure do |settings| | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not a huge fan of this but I'm not sure how best to configure the storage at the individual spec level. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where you'd use Lines 16 to 20 in bddb10b
It could just be added to some helper method. |
||||||||||||
settings.storage = LuckyCache::MemoryStore.new | ||||||||||||
end | ||||||||||||
|
||||||||||||
Habitat.raise_if_missing_settings! |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks so much for this ! I'd love to get a macro one liner with keeping the possibility to have a method coputing values at runtime like you proposed. Example : class RateLimitRoutes::Index < TestAction
include Lucky::RateLimit
rate_limit to: 1, within: 1.minute
get "/rate_limit" do
plain_text "hello"
end
end The dynamic setup still works as before : class ComputedRateLimitRoutes::Index < TestAction
include Lucky::RateLimit
get "/computed_rate_limit" do
plain_text "hello"
end
private def computed_rate_limit : NamedTuple(to: Int32, within: Time::Span)
{to: 1, within: 1.minute}
end
end
Here's what I got working locally : module Lucky::RateLimit
macro included
before enforce_rate_limit
end
macro rate_limit(**tuple)
private def computed_rate_limit : NamedTuple(to: Int32, within: Time::Span)
{{tuple}}
end
end
abstract def computed_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: computed_rate_limit["within"]) { 0 }
cache.write(rate_limit_key, expires_in: computed_rate_limit["within"]) { count + 1 }
if count > computed_rate_limit["to"]
context.response.status = HTTP::Status::TOO_MANY_REQUESTS
context.response.headers["Retry-After"] = computed_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 I'm sure there are still improvements macro wise but that's the idea. |
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("::", ":") | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I'm not sure if this matters here, but if we build it at compile time, then it won't need to compute on each request. |
||||||||||||||||||
"ratelimit:#{klass}:#{rate_limit_identifier}" | ||||||||||||||||||
end | ||||||||||||||||||
|
||||||||||||||||||
private def rate_limit_identifier : Socket::Address | Nil | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code is a copy of the RemoteIP code. You can override the method in your action, but the default needs something and I think the IP makes sense. The specs pass in a HTTP::Request so I don't get There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you should have access to lucky/src/charms/request_extensions.cr Lines 1 to 8 in bddb10b
This is actually patched in Lucky. Now, with that said, it brings up the point that if you don't have the |
||||||||||||||||||
request = context.request | ||||||||||||||||||
|
||||||||||||||||||
if x_forwarded = request.headers["X_FORWARDED_FOR"]?.try(&.split(',').first?).presence | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this can pull the header name from |
||||||||||||||||||
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is actually a pretty slick interface. It feels consistent with some other Lucky modules.