Rails 8 comes with simple built-in RateLimiting feature
Basic usage
You can apply a basic rate limit in your controller with the rate_limit directive:
class GreetingsController < ApplicationController
rate_limit to: 5, within: 1.minute, by: -> { request.ip }
def greet
render json: {message: "Hello, #{name}!"}, status: :ok
end
def goodbye
render json: {message: "Bye, #{name}!"}, status: :ok
end
private
def name
params.require(:name)
end
end
Multiple named rate limits
use :name argument when using multiple rate limits
class GreetingsController < ApplicationController
rate_limit to: 2, within: 1.second, by: -> { request.ip }, name: "short-term"
rate_limit to: 1000, within: 10.minutes, by: -> { request.ip }, name: "long-term"
def greet
render json: {message: "Hello, #{name}!"}, status: :ok
end
def goodbye
render json: {message: "Bye, #{name}!"}, status: :ok
end
private
def name
params.require(:name)
end
end
Rate limits per action
assign limit to a specific action by using :only argument:
class GreetingsController < ApplicationController
rate_limit to: 5, within: 1.minute, by: -> { request.ip }, name: "greet-limit", only: :greet
rate_limit to: 6, within: 70.seconds, by: -> { request.ip }, name: "bye-limit", only: :goodbye
def greet
render json: {message: "Hello, #{name}!"}, status: :ok
end
def goodbye
render json: {message: "Bye, #{name}!"}, status: :ok
end
private
def name
params.require(:name)
end
end
Adding Default Rate Limits
It's easy to extract the default rate limits into a module and include it in your controllers as default behavior.
We can use with
option to define a custom error handler.
module DefaultRateLimits
extend ActiveSupport::Concern
included do
rate_limit to: 45, within: 1.minute, by: -> { request.ip }, name: "long-term", with: -> { too_many_requests }
rate_limit to: 3, within: 2.seconds, by: -> { request.ip }, name: "short-term", with: -> { too_many_requests }
def too_many_requests(msg = nil)
err = "Rate limit exceeded."
if msg
err += " #{msg}."
end
render json: {error: err}, status: :too_many_requests
end
end
end
include DefaultRateLimits in your controller:
class GreetingsController < ApplicationController
include DefaultRateLimits
rate_limit to: 3, within: 10.minutes, by: -> { request.params["name"] }, name: "greet-limit", only: :greet, with: -> { too_many_requests("Try again in 10 minutes") }
rate_limit to: 5, within: 30.minutes, by: -> { request.params["name"] }, name: "goodbye-limit", only: :goodbye, with: -> { too_many_requests("Try again in 30 minutes") }
def greet
render json: {message: "Hello, #{name}!"}, status: :ok
end
def goodbye
render json: {message: "Bye, #{name}!"}, status: :ok
end
private
def name
params.require(:name)
end
end
These defaults apply to all actions unless overridden by more specific rate limits.
In this example the 'greet' action will have 3 rate limits applied concurrently:
- short-term: ip-based
- long-term: ip-based
- greet-limit: payload-based (params[:name])
The 429 response will look like:
{
"error": "Rate limit exceeded. Try again in 30 minutes."
}
Caching Strategies
Under the hood rate_limit uses Rails cache store (ActiveSupport::Cache)
We can override it by passing a custom store, e.g:
class APIController < ApplicationController
RATE_LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
rate_limit to: 10, within: 3.minutes, store: RATE_LIMIT_STORE
end
In production environments, it's recommended to use distributed caching systems such as Redis or Memcached for better performance and scalability. For development purposes, you can use ActiveSupport::Cache::MemoryStore, which is simpler and runs in memory.
# config/application.rb or config/environments
config.cache_store = :memory_store, { size: 64.megabytes }
Testing
Internally rate limit cache key implemented as:
def rate_limiting(to:, within:, by:, with:, store:, name:)
cache_key = ["rate-limit", controller_path, name, instance_exec(&by)].join(":")
count = store.increment(cache_key, 1, expires_in: within)
if count && count > to
ActiveSupport::Notifications.instrument("rate_limit.action_controller", request: request) do
instance_exec(&with)
end
end
end
When testing your API you can set the cache counter manually. This allows you to simulate throttled requests without waiting for the limit to be naturally hit. In your request/controller spec:
before do
cache_key = ["rate-limit", "greetings", "greet-limit", "Rachel"].join(":")
Rails.cache.increment(cache_key, 10, expires_in: 1.minutes)
end
it "returns 429" do
# Your test code here
end
Don't forget to reset the cache after each test:
after do
cache_key = ["rate-limit", "greetings", "greet-limit", "Rachel"].join(":")
Rails.cache.delete(cache_key)
end
or in rails_helper:
RSpec.configure do |config|
config.after(:each) do |example|
Rails.cache.clear
end
end
Summary
The RateLimiting module in Rails 8 offers a simple yet effective way to throttle requests, suitable for many applications from development through production.
It's easy to configure, helps prevent abuse, and integrates seamlessly with Rails controllers.
While ideal for most standard use cases, it may fall short in scenarios requiring dynamic rules, geo-based blocking, or distributed request coordination. For advanced rate limiting, consider libraries like rack-attack
.
Top comments (0)