docusealco/docuseal · warning · RateLimit::LimitApproached
RateLimit::LimitApproached
Error message
RateLimit::LimitApproached
What it means
RateLimit::LimitApproached (lib/rate_limit.rb:15) is raised when a named counter key exceeds its limit within the ttl window. The store is a process-local ActiveSupport::Cache::MemoryStore, so the count is per-process: each Puma worker / Sidekiq process keeps its own counter. It is the shared throttle behind OTP send/verify limits (2 calls per 45 seconds per IP or per email hash) and is only enabled by default in multitenant mode unless a caller forces enabled: true.
Source
Thrown at lib/rate_limit.rb:15
# frozen_string_literal: true
module RateLimit
LimitApproached = Class.new(StandardError)
STORE = ActiveSupport::Cache::MemoryStore.new
module_function
def call(key, limit:, ttl:, enabled: Docuseal.multitenant?)
return true unless enabled
value = STORE.increment(key, 1, expires_in: ttl)
raise LimitApproached if value > limit
true
end
end
View on GitHub (pinned to 004a22c1c8)
Solutions
- Wait out the ttl (45s) before retrying — the counter expires with the key.
- Rescue RateLimit::LimitApproached where it is expected and surface a friendly 'too many attempts, try later' message instead of a 500.
- For the OTP flows, submit the already-sent code instead of requesting a new one; verify attempts also count toward the limit.
- If legitimate shared-IP traffic trips it, raise limit/ttl or key by a narrower identifier (email instead of IP) at the call site.
Example fix
# before
RateLimit.call("send-otp-code-#{ip}", limit: 2, ttl: 45.seconds, enabled: true)
# after
begin
RateLimit.call("send-otp-code-#{ip}", limit: 2, ttl: 45.seconds, enabled: true)
rescue RateLimit::LimitApproached
render json: { error: 'Too many attempts. Try again in 45 seconds.' }, status: :too_many_requests
end Defensive patterns
Strategy: retry
Validate before calling
# Pre-check remaining budget when the store is reachable remaining = limit - (RateLimit::STORE.read(key).to_i || 0) raise RateLimit::LimitApproached if remaining <= 0
Try / catch
begin RateLimit.call(key, limit: 2, ttl: 45.seconds, enabled: true) rescue RateLimit::LimitApproached sleep(45) # or schedule a job at ttl expiry retry end
Prevention
- Remember the counter is process-local (MemoryStore) — limits are per Puma/Sidekiq process, not global.
- Rescue LimitApproached at the boundary and return 429 with a Retry-After equal to the ttl.
- Choose key granularity (per-IP vs per-email) so shared NATs do not throttle unrelated users.
- Count sends and verifies together — each verify attempt also consumes budget.
When it happens
Trigger: RateLimit.call('send-otp-code-<ip>', limit: 2, ttl: 45.seconds, enabled: true) hit a 3rd time within 45s from the same remote IP; RateLimit.call("verify-2fa-code-<md5(email)>") called more than twice in 45s for the same email; any custom call whose key collides (same key reused for unrelated traffic).
Common situations: Many users behind one NAT/proxy IP (office, VPN, corporate proxy) tripping the per-IP OTP throttle; users double-clicking 'send code'; frontends retrying failed verifications in a loop; multi-process deployments where the MemoryStore counter silently resets per process, making limits looser than configured.
Related errors
AI-assisted analysis of docusealco/docuseal@004a22c1c8 (2026-08-21).
Data as JSON: /api/errors/ef324e262e9a0445.
Report an issue: GitHub.