We can easily configure global rate limits, but how can we make them dynamic?

In a previous post I explored how to add account-specific IP whitelisting in a Rails/rack app. This post explores similar problem space, this time adding per-account rate limiting.

We generally add global rate limits via nginx or CDN - a limit of X requests per minute/hour for a particular IP. But some customer accounts in my application needed extra limits for API usage and were willing to pay more for it 💰, however the default limit should stay for remaining customers. This meant the global CDN rate limit would not work.

rack-attack ⚡️

Rate limiting is hard to implement from scratch. We need to count the number of requests during an interval (generally per minute but could also be per hour). rack-attack's throttle configuration handles this precisely: it restricts requests to a given limit within a given period.

A very basic config would look like this:

# config/initializers/rack_attack.rb

Rack::Attack.throttle("requests per API token", limit: 20, period: 60) do |request|
  request.headers["Api-Token"]
end

This will only allow 20 requests per minute for an API token.

But that limit of 20 requests is static and configured once in the initializer. Our requirement demands dynamic limit per account to be taken from database. For this, rack-attack provides an option to pass a limit proc to be called for each request to get the limit.

# config/initializers/rack_attack.rb

Rack::Attack.throttle('request per API token', limit: limit_proc, period: 60) do |request|
  request.headers["Api-Token"]
end

And our limit_proc can use the API token to fetch associated account and its limit from the database.

# config/initializers/rack_attack.rb

limit_proc = proc do |request|
  token = request.headers["Api-Token"]

  custom_limit = ApiToken.find_by(token:)&.account&.rate_limit

  (custom_limit || DEFAULT_RATE_LIMIT).to_i
end

rack-attack also allows passing proc for period, but we don't need it in our case so we keep it fixed to 60 seconds.

This works but the proc is called for every request which means some SQL queries are fired on every request, including invalid ones with unrecognised tokens. This can be optimized using cache:

# config/initializers/redis.rb

REDIS = ConnectionPool.new(size: 20, timeout: 5) do
  Redis.new(url: ENV['REDIS_URL'])
end

I am using Redis here, but you can easily replace it with any other cache store like Rails.cache. To see why I use a connection pool, read my previous blog.

The cache entry is written whenever an account's rate limit is saved.

# app/models/account.rb

def set_rate_limit(token, rate_limit)
  cache_key = "api_rate_limit:#{token}"

  REDIS.with do |conn|
    conn.set(cache_key, rate_limit)
  end
end

And rack-attack reads it on each request, so our hot path is free of database calls.

# config/initializers/rack_attack.rb

limit_proc = proc do |request|
  token = request.headers["Api-Token"]

  custom_limit = REDIS.with do |conn|
    conn.get("api_rate_limit:#{token}")
  end

  (custom_limit || DEFAULT_RATE_LIMIT).to_i
end

Thus with rack-attack throttle and Redis cache, we have a performant, per-account configurable rate limiting.

Rails built-in rate-limit

A quick note on why I did could not use Rails' own rate-limit:

  • My app was running an older version of Rails which did not have the built-in rate limit.
  • At the time of writing this (May 2026), there are a couple things that Rails rate limiter does not support:
    • Dynamic(callable) arguments as limit. Added recently but not yet released.
    • Rate limit on something other than remote_ip (API token in our example). Also added recently but not yet released.