ruby 78 lines · 3 tabs

Rate-Limiting a Sinatra JSON API With Rack::Attack Throttles and Redis

Shared by codesnips Jul 2026
3 tabs
require "rack/attack"
require "redis-store"

class Rack::Attack
  cache.store = Redis::Store.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"))

  safelist("allow localhost") do |req|
    ["127.0.0.1", "::1"].include?(req.ip)
  end

  throttle("req/ip", limit: 100, period: 60) do |req|
    req.ip unless req.path.start_with?("/assets")
  end

  throttle("logins/token", limit: 5, period: 60) do |req|
    if req.post? && req.path == "/login"
      req.env["HTTP_X_API_TOKEN"] || req.ip
    end
  end

  self.throttled_responder = lambda do |req|
    match = req.env["rack.attack.match_data"] || {}
    now   = Time.now.to_i
    retry_after = match[:period] - (now % (match[:period] || 1))

    headers = {
      "Content-Type"        => "application/json",
      "Retry-After"         => retry_after.to_s,
      "RateLimit-Limit"     => match[:limit].to_s,
      "RateLimit-Remaining" => [(match[:limit] || 0) - (match[:count] || 0), 0].max.to_s
    }

    body = { error: "rate_limited", retry_after: retry_after }.to_json
    [429, headers, [body]]
  end
end
3 files · ruby Explain with highlit

This snippet shows how a Sinatra JSON API enforces per-client rate limits using Rack::Attack, a Rack middleware that intercepts requests before they reach the application. Rate limiting protects an endpoint from abuse, accidental request storms, and credential-stuffing by capping how many requests a given key (IP or API token) may make inside a rolling window. The pattern is attractive because it lives entirely in middleware: no route handler needs to know about it, and the cap is enforced uniformly across the app.

In config/rack_attack.rb, the store is set to a Redis::Store via Rack::Attack.cache.store, which matters because throttle counters must be shared across every worker process and server — an in-memory store would let each process count independently and multiply the real limit. The throttle block for "req/ip" returns a discriminator (req.ip) for general traffic, while "logins/token" narrows to POST /login and keys on the API token so authentication attempts are limited independently of read traffic. Returning nil from a throttle block skips that rule for the request, which is how the login throttle ignores unrelated paths.

The throttled_responder builds a proper 429 Too Many Requests response with a JSON body and Retry-After, RateLimit-Limit, and RateLimit-Remaining headers derived from match_data, so well-behaved clients can back off instead of hammering. The safelist for localhost keeps development and health checks from being throttled.

In app.rb, the Sinatra application simply use Rack::Attack after requiring the config. Because middleware ordering is significant, Rack::Attack is inserted early so throttling happens before routing and session work. The /login and /search routes stay oblivious to the limiter — they only return data. The config.ru rackup file wires the environment and boots the app.

A key trade-off is that fixed-window counting (what throttle uses) can allow short bursts at window boundaries; for stricter smoothing a token-bucket approach would be needed. Another pitfall is keying on req.ip behind a proxy, where the real client address must come from a trusted forwarded header rather than the socket peer.


Related snips

Share this code

Here's the card — post it anywhere.

Rate-Limiting a Sinatra JSON API With Rack::Attack Throttles and Redis — share card
Link copied