Skip to content
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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

russ
Copy link
Contributor

@russ russ commented Oct 6, 2024

Purpose

Adds a way to set per action rate limits.
Fixes #1865

Description

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.

Checklist

  • - An issue already exists detailing the issue/or feature request that this PR fixes
  • - All specs are formatted with crystal tool format spec src
  • - Inline documentation has been added and/or updated
  • - Lucky builds on docker with ./script/setup
  • - All builds and specs pass on docker with ./script/test

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
@@ -48,4 +48,8 @@ Lucky::ForceSSLHandler.configure do |settings|
settings.enabled = true
end

LuckyCache.configure do |settings|
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where you'd use temp_config and put the spec inside of that block. Here's an example:

private def with_test_template(&)
Lucky::Exec.temp_config(template_path: "spec/support/exec_template.cr.template") do
yield
end
end

It could just be added to some helper method.

"ratelimit:#{klass}:#{rate_limit_identifier}"
end

private def rate_limit_identifier : Socket::Address | Nil
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 context.remote_ip.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should have access to request.remote_ip

class HTTP::Request
# This is an alternative to `remote_address`
# since that casts to `Socket::Address`, and not all
# subclasses have an `address` method to give you the value.
# ```
# request.remote_address.as?(Socket::IPAddress).try(&.address)
# ```
property remote_ip : String = ""

This is actually patched in Lucky. context doesn't have a remote_ip method, but you may be thinking of context.request_id which is something different.

Now, with that said, it brings up the point that if you don't have the RemoteIpHandler in your middleware stack, that could cause issues... I wonder if there's a way that we could say you must have that handler in your stack in order to use this module? 🤔

@jwoertink jwoertink added the hacktoberfest-accepted PRs accepted for Hacktoberfest label Oct 7, 2024
Copy link
Member

@jwoertink jwoertink left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is amazing 🚀 Thanks for tackling this. I think we should definitely add LuckyCache as a dependency here to solve the spec failure. There's stuff in LuckyCache for doing view caching anyway, so it makes sense. I left a few other comments too.

@@ -48,4 +48,8 @@ Lucky::ForceSSLHandler.configure do |settings|
settings.enabled = true
end

LuckyCache.configure do |settings|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where you'd use temp_config and put the spec inside of that block. Here's an example:

private def with_test_template(&)
Lucky::Exec.temp_config(template_path: "spec/support/exec_template.cr.template") do
yield
end
end

It could just be added to some helper method.

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

if x_forwarded = request.headers["X_FORWARDED_FOR"]?.try(&.split(',').first?).presence
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this can pull the header name from Lucky::RemoteIpHandler.settings.ip_header_name ?

"ratelimit:#{klass}:#{rate_limit_identifier}"
end

private def rate_limit_identifier : Socket::Address | Nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should have access to request.remote_ip

class HTTP::Request
# This is an alternative to `remote_address`
# since that casts to `Socket::Address`, and not all
# subclasses have an `address` method to give you the value.
# ```
# request.remote_address.as?(Socket::IPAddress).try(&.address)
# ```
property remote_ip : String = ""

This is actually patched in Lucky. context doesn't have a remote_ip method, but you may be thinking of context.request_id which is something different.

Now, with that said, it brings up the point that if you don't have the RemoteIpHandler in your middleware stack, that could cause issues... I wonder if there's a way that we could say you must have that handler in your stack in order to use this module? 🤔

end

private def rate_limit_key : String
klass = self.class.to_s.downcase.gsub("::", ":")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
klass = self.class.to_s.downcase.gsub("::", ":")
klass = {{ @type.stringify.downcase.gsub(/::/, ":") }}

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.

Comment on lines +12 to +14
private def rate_limit : NamedTuple(to: Int32, within: Time::Span)
{to: 1, within: 1.minute}
end
Copy link
Member

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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

@rmarronnier
Copy link
Contributor

@russ : Do you plan on continuing you work on this ? If not, how would you feel if I pick it up ?

@russ
Copy link
Contributor Author

russ commented Dec 4, 2024

@russ : Do you plan on continuing you work on this ? If not, how would you feel if I pick it up ?

@rmarronnier I keep meaning to come back to this. Please, feel free to pick it up if you have the time. 😄

@rmarronnier
Copy link
Contributor

@russ : Do you plan on continuing you work on this ? If not, how would you feel if I pick it up ?

@rmarronnier I keep meaning to come back to this. Please, feel free to pick it up if you have the time. 😄

Ok ! I might start really working on this next month, so let's see who's first :-p

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
hacktoberfest-accepted PRs accepted for Hacktoberfest
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Action based rate limiting
3 participants