Skip to content

Conversation

casperisfine
Copy link
Contributor

Given that the limiter implementation provided by Kredis is a simple increment with a limit, all ActiveSupport::Cache already provide that same capability, with a wide range of backing stores, and not just Redis.

This even allow to use SolidCache has a backend if you so desire.

If we feel particularly fancy, we could also accept a more generic limiter interface to better allow users to swap the implementation for better algorithms such as leaky-bucket etc.

FYI @dhh @rafaelfranca

Make sure they all increment the counter but don't update the TTL.
Given that the limiter implementation provided by Kredis is a simple
increment with a limit, all `ActiveSupport::Cache` already provide that
same capability, with a wide range of backing stores, and not just Redis.

This even allow to use SolidCache has a backend if you so desire.

If we feel particularly fancy, we could also accept a more generic
limiter interface to better allow users to swap the implementation
for better algorithms such as leaky-bucket etc.
@dhh
Copy link
Member

dhh commented Jan 17, 2024

Beautiful! 👌

@byroot byroot merged commit 1205a75 into rails:main Jan 17, 2024
Copy link
Member

@jonathanhefner jonathanhefner left a comment

Choose a reason for hiding this comment

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

Awesome! ❤️

Comment on lines +18 to +20
# Rate limiting relies on a backing <tt>ActiveSupport::Cache</tt> store and defaults to <tt>config.action_controller.cache_store</tt>, which
# itself default to the global `config.cache_store`. If you don't want to store rate limits in the same datastore than your general caches
# you can pass a custom store in the <tt>store</tt> parameter.
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
# Rate limiting relies on a backing <tt>ActiveSupport::Cache</tt> store and defaults to <tt>config.action_controller.cache_store</tt>, which
# itself default to the global `config.cache_store`. If you don't want to store rate limits in the same datastore than your general caches
# you can pass a custom store in the <tt>store</tt> parameter.
# Rate limiting relies on a backing ActiveSupport::Cache store and defaults to <tt>config.action_controller.cache_store</tt>, which
# itself defaults to the global <tt>config.cache_store</tt>. If you don't want to store rate limits in the same datastore as your general caches,
# you can pass a custom store in the <tt>store</tt> parameter.


class RateLimitedController < ActionController::Base
self.cache_store = ActiveSupport::Cache::MemoryStore.new
rate_limit to: 2, within: 2.seconds, by: -> { Thread.current[:redis_test_seggregation] }, only: :limited_to_two
Copy link
Member

Choose a reason for hiding this comment

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

Since we're no longer using a shared store, I think we can drop :by here and on line 13 (and the assignment in setup).

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 it's fine to test the by: behavior.

Copy link
Member

Choose a reason for hiding this comment

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

As far as I can see, it's not actually testing the :by behavior. These args were added in 0868698 simply to prevent flaky CI. Also, by specifying :by here, we do not exercise the default value of :by.

Comment on lines 213 to +215
file_name = normalize_key(name, options)
options = merged_options(options)
key = normalize_key(name, options)
Copy link
Member

Choose a reason for hiding this comment

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

Can reuse file_name here. Or perhaps better: assign file_name = key afterward.

Copy link
Member

Choose a reason for hiding this comment

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

Hum, no because we use both: lock_file(file_name), read_entry(key, ...)

Copy link
Member

Choose a reason for hiding this comment

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

By "reuse" I meant set key = file_name because they are both normalize_key(name, options) (and normalize_key will handle merge_options internally).

But I actually think it would be nicer to set file_name = key afterward:

options = merged_options(options)
key = normalize_key(name, options)
version = normalize_version(name, options)
file_name = key
amount = Integer(amount)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤦 OK, I think I'm tired tonight. I'll set a reminder to clean this up tomorrow because clearly I'm done for today.

casperisfine pushed a commit to Shopify/rails that referenced this pull request Jan 18, 2024
Followup: rails#50781

Some small issues I failed to address in the original PR.
@dhh
Copy link
Member

dhh commented Jan 24, 2024

Problem with this in testing is that we default to null_store. So the value doesn't persist. So you can't test it. Thoughts @casperisfine?

@byroot
Copy link
Member

byroot commented Jan 24, 2024

Problem with this in testing is that we default to null_store. So the value doesn't persist. So you can't test it.

Right. Didn't think of that because we tend to have properly configured cache in test.

In your specific case, since you wanted Redis anyway, you can just explicitly pass it a Redis store.

But your point is valid for the broader community. My first answer would be that we could detect when it's NullStore and fallback to a MemoryStore, but that sounds a bit too brittle.

I'll think about it and try to come up with something.

@dhh
Copy link
Member

dhh commented Jan 24, 2024

Yeah, I'm almost thinking whether we should swap that default cache backend for something like MemoryStore and then just reset it instead? As the default?

@byroot
Copy link
Member

byroot commented Jan 25, 2024

we should swap that default cache backend for something like MemoryStore and then just reset it instead? As the default?

I think it's a better default yes. I always setup cache in test because I also always have code depending on it in some way.

But it does indeed require a call to .clear after each test. Some people may object to this because they use the same store from dev and test, or use a single store with parallel tests (see #48341).

So this might open some kind of can of worm, but would probably be an improvement.

@dhh
Copy link
Member

dhh commented Jan 25, 2024

I think we should default to MemoryStore, but make it a dedicated test instance, and then also ensure #clear is called by default.

@34code
Copy link

34code commented May 14, 2024

will it be possible to rate_limit by current user id?

@34code
Copy link

34code commented May 14, 2024

maybe something like this is supported?

rate_limit to: 4, within: 1.minute, by: -> { current_user.id }

@oliwoodsuk
Copy link

I might be missing something, but doesn't the controller_path method error out when the route requires an id? for example in

#routes.rb
resources :listings, only: [::create]

#controller.rb
rate_limit to: 20, within: 10.seconds

def create
end

raises:

ActionController::UrlGenerationError: No route matches {:action=>"show", :controller=>"controllers", :format=>"json"}, missing required keys: [:id]

I've pasted the RateLimiting concern into a Rails 7.1 project and am currently using this code in the private section of the code.

   def rate_limiting(to:, within:, by:, with:, store:)
    count = store.increment("rate-limit:#{rate_limiting_route_scope}:#{instance_exec(&by)}", 1, expires_in: within)

    if count && count > to
      ActiveSupport::Notifications.instrument("rate_limit.action_controller", request: request) do
        instance_exec(&with)
      end
    end
  end

  def rate_limiting_route_scope #instead of just `controller_path`
    method(:controller_path).arity.nonzero? ? controller_path(1) : controller_path
  end

Maybe I'm missing something, but couldn't see any coverage in the tests to prove me wrong.

@byroot
Copy link
Member

byroot commented Jul 27, 2024

No. controller_path isn't a route URL, it's a cache key generated from the controller class name:

def controller_path
@controller_path ||= name.delete_suffix("Controller").underscore unless anonymous?
end

@oliwoodsuk
Copy link

@byroot Got it, thanks for the reply

yoones pushed a commit to yoones/rails that referenced this pull request Mar 6, 2025
Followup: rails#50781

Some small issues I failed to address in the original PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants