Hanikamu::RateLimit
Distributed, Redis-backed rate limiting for Ruby. Coordinates request throughput across processes and threads so you never exceed an API's quota — even with dozens of workers.
Table of Contents
- Quick Start
- Configuration
- Usage
- Inline limits
- Shared limits (registry)
- Dynamic overrides from API headers
- Class methods
- Callbacks
- Resetting limits
- Background Jobs
- UI Dashboard
- Error Handling
- Testing
- Development
- License
Quick Start
Requires Ruby 4.0+ and a running Redis instance.
1. Install the gem
# Gemfile
gem "hanikamu-rate-limit", "~> 0.4"bundle install2. Configure Redis
# config/initializers/hanikamu_rate_limit.rb
Hanikamu::RateLimit.configure do |config|
config.redis_url = ENV.fetch("REDIS_URL")
end3. Add a limit to any method
class MyService
extend Hanikamu::RateLimit::Mixin
# Allow at most 5 calls per second
limit_method :execute, rate: 5, interval: 1.0
def execute
# work
end
end
MyService.new.execute # waits automatically if the limit is reachedThat's it. The limiter coordinates across all processes sharing the same Redis instance.
Configuration
Hanikamu::RateLimit.configure do |config|
# Required
config.redis_url = ENV.fetch("REDIS_URL")
# Optional — tune how the limiter waits when a limit is reached
config.check_interval = 0.25 # how often to retry (seconds)
config.max_wait_time = 1.5 # give up and raise after this many seconds
config.wait_strategy = :sleep # :sleep (block the thread) or :raise (raise immediately)
config.jitter = 0.15 # add up to 15 % random spread to prevent thundering herds
config.metrics_enabled = true # required if you want the UI dashboard
# Named limits — share one quota across multiple classes
config.register_limit(:external_api,
rate: 20, interval: 1.0, # 20 requests per second
check_interval: 0.1, max_wait_time: 5
)
endGlobal settings
| Setting | Default | Description |
|---|---|---|
redis_url |
— | Redis connection URL (required). |
check_interval |
0.5 |
Seconds between retries when a limit is hit. |
max_wait_time |
2.0 |
Max seconds to wait before raising RateLimitError. |
wait_strategy |
:sleep |
:sleep blocks the thread; :raise raises immediately. |
jitter |
0.0 |
Random spread added to wait times to prevent thundering herds. |
metrics_enabled |
false |
Enable metrics collection. Must be true for the UI dashboard. |
Registered limit options
| Option | Required | Description |
|---|---|---|
rate |
Yes | Max requests allowed per interval. |
interval |
Yes | Time window in seconds. |
check_interval |
No | Override global check_interval for this limit. |
max_wait_time |
No | Override global max_wait_time for this limit. |
metrics |
No | Override metrics_enabled for this limit (true / false). |
Usage
Inline limits
Best for limits that apply to a single class. Pass rate: and interval: directly:
class MyService
extend Hanikamu::RateLimit::Mixin
# 5 requests per second
limit_method :execute, rate: 5, interval: 1.0
# 10 requests per minute, with custom wait settings and metrics disabled
limit_method :fetch, rate: 10, interval: 60, check_interval: 0.1, max_wait_time: 3.0, metrics: false
def execute = "done"
def fetch = "fetched"
endShared limits (registry)
Best when multiple classes must share the same quota (e.g. different services calling the same external API). Define the limit once in the initializer, then reference it by name:
# config/initializers/hanikamu_rate_limit.rb
Hanikamu::RateLimit.configure do |config|
config.redis_url = ENV.fetch("REDIS_URL")
config.register_limit(:external_api, rate: 20, interval: 1.0)
endclass ServiceA
extend Hanikamu::RateLimit::Mixin
limit_method :call, registry: :external_api
def call = "a"
end
class ServiceB
extend Hanikamu::RateLimit::Mixin
limit_method :call, registry: :external_api
def call = "b"
endBoth classes count against the same 20 req/s quota in Redis. You cannot combine registry: with inline options like rate: or interval:.
Dynamic overrides from API headers
Only works with registry-based limits.
Many APIs return rate-limit headers telling you how many requests you have left and when the window resets. You can feed these directly into the gem so it respects the API's actual limits:
Hanikamu::RateLimit.register_temporary_limit(
:external_api,
remaining: response.headers["X-RateLimit-Remaining"],
reset: response.headers["X-RateLimit-Reset"],
reset_kind: :unix
)While active, the gem uses this temporary limit instead of the registered one. When it expires, the original limit resumes automatically.
reset_kind option
APIs express the reset value in different formats. Use reset_kind: to tell the gem what you're passing:
reset_kind |
What to pass | Example |
|---|---|---|
:seconds |
Seconds until reset (default) | reset: 60 |
:unix |
Unix timestamp (int/string) | reset: 1740000000 |
:datetime |
Time or DateTime object |
reset: Time.now + 60 |
Safety: With
:seconds(default), values above 86,400 raiseArgumentErrorto catch accidental Unix timestamps.
Full example — API client with dynamic overrides
class ExternalApiClient
extend Hanikamu::RateLimit::Mixin
limit_method :call, registry: :external_api
def call
response = http_client.get("/endpoint")
if response.headers["X-RateLimit-Remaining"]
Hanikamu::RateLimit.register_temporary_limit(
:external_api,
remaining: response.headers["X-RateLimit-Remaining"],
reset: response.headers["X-RateLimit-Reset"],
reset_kind: :unix # or :seconds depending on the API
)
end
response
end
endWhat happens when remaining reaches 0?
- If the reset time is longer than
max_wait_time→RateLimitErroris raised immediately. - If the reset time is shorter than
max_wait_time→ the limiter waits, then resumes with the original limit.
Class methods
Apply the mixin to the singleton class:
class MyService
class << self
extend Hanikamu::RateLimit::Mixin
limit_method :call, registry: :external_api
def call = "work"
end
endCallbacks
An optional block is called each time the limiter waits:
limit_method :execute, rate: 5, interval: 1.0 do |sleep_time|
Rails.logger.info("Rate limited, waiting #{sleep_time}s")
endResetting limits
If something goes wrong — a deploy changes upstream quotas, a temporary override becomes stale, or you need to recover from a stuck state — you can clear a limit so the quota starts fresh:
Hanikamu::RateLimit.reset_limit!(:external_api)
# => true (clears the counter and any active temporary override)Inline limits get an auto-generated reset method on the class:
MyService.reset_execute_limit!Background Jobs
The problem
With the default :sleep strategy, a rate-limited call blocks the worker thread. If enough jobs hit the limit at once, all your Sidekiq threads can stall.
The solution
JobRetry makes rate-limited jobs re-enqueue themselves instead of blocking. The thread is freed instantly and the job retries after the wait period.
ActiveJob (default)
class RateLimitedJob < ApplicationJob
extend Hanikamu::RateLimit::JobRetry
rate_limit_retry
def perform
MyService.new.execute
end
endSidekiq native workers
For workers that use include Sidekiq::Worker (or Sidekiq::Job) directly, pass worker: :sidekiq. Requires Sidekiq >= 8.1.
class RateLimitedWorker
include Sidekiq::Worker
extend Hanikamu::RateLimit::JobRetry
rate_limit_retry worker: :sidekiq
def perform
MyService.new.execute
end
endThe Sidekiq path uses sidekiq_retry_in for backoff timing and sidekiq_options retry: for the retry cap. attempts always means total executions (initial run + retries), so attempts: 5 maps to sidekiq_options retry: 4. Non-RateLimitError exceptions use Sidekiq's default backoff.
The same service still works normally outside of jobs — it sleeps as expected when called synchronously.
rate_limit_retry options
| Option | Default | Description |
|---|---|---|
attempts |
:unlimited |
Total executions (initial + retries). :unlimited retries forever. |
fallback_wait |
5 |
Seconds to wait if the error has no retry_after value. |
worker |
:active_job |
:active_job for ActiveJob, :sidekiq for native Sidekiq workers. |
extend Hanikamu::RateLimit::JobRetry
rate_limit_retry attempts: 20, fallback_wait: 10, worker: :sidekiqJitter
When many jobs retry at the same instant they can create a spike. jitter adds random spread to prevent this:
Hanikamu::RateLimit.configure do |config|
config.jitter = 0.15 # adds 0–15 % random spread to each wait
endJitter applies globally — it smooths out both synchronous waits and job retries.
Manual strategy override
JobRetry switches to the :raise strategy automatically. You can do the same thing anywhere — useful in controllers, scripts, or tests where you want to catch the error yourself:
Hanikamu::RateLimit.with_wait_strategy(:raise) do
MyService.new.execute # raises RateLimitError instead of sleeping
endUI Dashboard
A built-in dashboard with real-time updates. Requires Rails (actionpack, actionview, railties >= 6.1) — these are already present in any Rails app.
Setup
1. Enable metrics
Hanikamu::RateLimit.configure do |config|
config.metrics_enabled = true
end2. Mount the engine
# config/routes.rb
require "hanikamu/rate_limit/ui"
Rails.application.routes.draw do
mount Hanikamu::RateLimit::UI::Engine => "/rate-limits"
end3. Configure authentication
The dashboard is deny-by-default — all endpoints return 403 until you configure ui_auth:
Hanikamu::RateLimit.configure do |config|
# Local requests only
config.ui_auth = ->(controller) { controller.request.local? }
# Devise / Warden
config.ui_auth = ->(controller) { controller.request.env["warden"]&.user&.admin? }
# Session-based
config.ui_auth = ->(controller) { controller.session[:admin] == true }
# Always allow (development only)
config.ui_auth = -> { Rails.env.development? }
endThe callable receives the engine's DashboardController instance. When it returns falsy or raises, a 401 is returned.
What the dashboard shows
- Summary — total limits tracked, window and bucket sizes.
- Redis info — version, memory usage, connected clients (updates live).
- Per-limit cards — current rate, requests/sec, blocked/sec, rolling counters (5 min, 24 h, all-time), charts with blocked-period highlighting, and override status.
SSE connection limit
The dashboard streams live updates via Server-Sent Events. Each connection holds a thread for up to 1 minute (reconnects automatically). You can cap concurrent connections:
config.ui_max_sse_connections = 5 # conservative (default: 10)
config.ui_max_sse_connections = nil # no limit (not recommended)Metrics settings
| Setting | Default | Description |
|---|---|---|
metrics_bucket_seconds |
300 |
Bucket size for the 24-hour chart (5 min default) |
metrics_window_seconds |
86_400 |
How far back the 24-hour chart goes |
metrics_realtime_bucket_seconds |
1 |
Bucket size for the 5-minute chart (1 sec default) |
metrics_realtime_window_seconds |
300 |
How far back the 5-minute chart goes |
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /rate-limits |
HTML dashboard |
| GET | /rate-limits/metrics |
JSON snapshot of all metrics |
| GET | /rate-limits/stream |
SSE stream (event: metrics) |
Error Handling
| Scenario | Behaviour |
|---|---|
| Redis unavailable | Logs a warning and allows the request through (fail-open). |
Rate limited (:sleep) |
Blocks up to max_wait_time, then raises RateLimitError. |
Rate limited (:raise) |
Raises RateLimitError immediately with a retry_after value. |
Catching the error:
begin
service.execute
rescue Hanikamu::RateLimit::RateLimitError => e
e.retry_after # => 0.42 (seconds until a slot opens)
endTesting
Testing your app
In tests you generally want rate limits to raise immediately instead of blocking. Use the :raise strategy and rescue the error:
around do |example|
Hanikamu::RateLimit.with_wait_strategy(:raise) { example.run }
endOr stub the rate-limited method to bypass the limiter entirely when it's not relevant to the test.
Running the gem's own tests
make rspecDevelopment
make shell # bash inside the container
make cops # RuboCop with auto-correct
make console # IRB with the gem loaded
make bundle # rebuild after Gemfile changesLicense
MIT