ruby 102 lines · 3 tabs

Sliding-Window API Rate Limiting with a Rack Middleware and Redis in Rails

Shared by codesnips Aug 2026
3 tabs
module Middleware
  class RateLimiter
    def initialize(app, redis:, limit:, window:)
      @app = app
      @limiter = SlidingWindowLimiter.new(redis: redis, limit: limit, window: window)
      @limit = limit
    end

    def call(env)
      request = Rack::Request.new(env)
      key = client_key(request)

      result = @limiter.hit(key)
      return too_many_requests(result) unless result.allowed?

      status, headers, body = @app.call(env)
      inject_headers(headers, result)
      [status, headers, body]
    rescue Redis::BaseError
      @app.call(env) # fail open if Redis is down
    end

    private

    def client_key(request)
      api_key = request.get_header("HTTP_X_API_KEY")
      "rl:#{api_key.presence || request.ip}"
    end

    def too_many_requests(result)
      headers = { "Content-Type" => "application/json", "Retry-After" => result.retry_after.to_s }
      inject_headers(headers, result)
      body = { error: "rate_limit_exceeded", retry_after: result.retry_after }.to_json
      [429, headers, [body]]
    end

    def inject_headers(headers, result)
      headers["X-RateLimit-Limit"] = @limit.to_s
      headers["X-RateLimit-Remaining"] = result.remaining.to_s
      headers["X-RateLimit-Reset"] = result.reset_at.to_s
    end
  end
end
3 files · ruby Explain with highlit

This snippet shows how a per-client rate limiter is implemented as a Rack middleware backed by Redis, and how it is wired into the Rails middleware stack. Rate limiting belongs at the edge of the request lifecycle, before controllers and even before most of Rails runs, which is exactly where Rack middleware sits — so a rejected request never pays the cost of routing, authentication, or ActiveRecord.

The RateLimiter middleware implements the call(env) contract every Rack app follows. It builds a Rack::Request, derives a client identity from either an API key header or the remote IP, and asks a small window counter whether the client is over budget. When the limit is exceeded it short-circuits with a 429 Too Many Requests response and a Retry-After header instead of calling @app. On the happy path it forwards the request and injects informational X-RateLimit-* headers into the downstream response so clients can self-throttle.

The counting logic lives in SlidingWindowLimiter, which uses a Redis sorted set as a true sliding window rather than a fixed calendar bucket. Fixed windows suffer from a burst problem at boundaries: a client can send a full quota at 00:59 and another full quota at 01:00. The sorted set stores one member per request scored by timestamp, so on each call the code prunes entries older than the window with ZREMRANGEBYSCORE, counts what remains, and decides. Crucially, prune, count, add, and expire are executed atomically inside a Lua script; without atomicity two concurrent requests could both read a count under the limit and both be admitted, blowing past the quota. The script returns the current count so the middleware can compute remaining budget without a second round trip.

The Application config registers the middleware with config.middleware.use, passing the shared Redis client, the limit, and the window. Trade-offs worth noting: IP-based identity is weak behind proxies, so trusting X-Api-Key first is deliberate; the sorted set costs more memory than a plain counter but buys smoothness; and Redis being unavailable should generally fail open, which the rescue in call handles so an outage in the limiter never takes down the API.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
payload = {
  sub: user.id,
  iss: 'https://auth.example.com',
  aud: 'codesnips-api',
  exp: 15.minutes.from_now.to_i,
  iat: Time.now.to_i,

JWT issuance and verification without common footguns

jwt authentication api
by Kai Nakamura 2 tabs
typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Sliding-Window API Rate Limiting with a Rack Middleware and Redis in Rails — share card
Link copied