ruby 99 lines · 3 tabs

Rate Limiting with Redis + Increment Expiry

Shared by codesnips Jan 2026
3 tabs
class SlidingWindowLimiter
  Result = Struct.new(:allowed, :count, :limit, :window_ms, keyword_init: true)

  SCRIPT = <<~LUA.freeze
    local key = KEYS[1]
    local now = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
    local count = redis.call('ZCARD', key)
    if count < limit then
      redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
      redis.call('PEXPIRE', key, window)
      return {1, count + 1}
    end
    return {0, count}
  LUA

  def initialize(redis:, limit:, window_ms:)
    @redis = redis
    @limit = limit
    @window_ms = window_ms
    @sha = @redis.script(:load, SCRIPT)
  end

  def allow?(client_key)
    now = (Time.now.to_f * 1000).to_i
    allowed, count = eval_script("ratelimit:#{client_key}", now)
    Result.new(allowed: allowed == 1, count: count, limit: @limit, window_ms: @window_ms)
  end

  private

  def eval_script(key, now)
    @redis.evalsha(@sha, keys: [key], argv: [now, @window_ms, @limit])
  rescue Redis::CommandError => e
    raise unless e.message.include?("NOSCRIPT")
    @sha = @redis.script(:load, SCRIPT)
    retry
  end
end
3 files · ruby Explain with highlit

Rate limiting protects an API from abusive or accidental traffic bursts, and a sliding window implementation avoids the boundary spikes that a naive fixed-window counter suffers from. The classic problem with fixed windows is that a client can send a full quota at the end of one window and another full quota at the start of the next, effectively doubling the allowed rate around the boundary. This snippet models a per-client window using a Redis sorted set (ZSET) keyed by request timestamp, which gives a true rolling window at the cost of storing one member per request.

In SlidingWindowLimiter, the entire decision is pushed into a single Lua script executed with EVALSHA. Running everything server-side in Redis is deliberate: it makes the read-count-add-expire sequence atomic, so concurrent requests from the same client cannot race between checking the count and recording their own hit. The script first calls ZREMRANGEBYSCORE to evict entries older than the window, then ZCARD to count what remains, and only if the count is under limit does it ZADD the new request and refresh the key's TTL with PEXPIRE. The TTL is set to the window length on every allowed call so idle keys expire on their own, keeping memory bounded without a separate sweeper.

The script returns both an allowed flag and the current count, letting the caller compute the standard X-RateLimit-* headers. allow? wraps this and falls back through NOSCRIPT handling, reloading the script if Redis was flushed or failed over. A key trade-off worth noting: because each request adds a member, memory scales with request volume within the window, so very high limits are better served by an approximate counter.

The RackAttackThrottle initializer wires the limiter into the request path as Rack middleware, deriving a client key from the API token or IP and short-circuiting with a 429 plus a Retry-After header when allow? returns false. Keeping the throttling logic in middleware means it runs before controllers, ActiveRecord, and view rendering, rejecting excess load as cheaply as possible. The controller in ApiBaseController reads the limiter result exposed on the Rack env to attach informational headers on successful responses, so well-behaved clients can self-pace against their remaining budget.


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.

Rate Limiting with Redis + Increment Expiry — share card
Link copied