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

  1. Wait out the ttl (45s) before retrying — the counter expires with the key.
  2. Rescue RateLimit::LimitApproached where it is expected and surface a friendly 'too many attempts, try later' message instead of a 500.
  3. For the OTP flows, submit the already-sent code instead of requesting a new one; verify attempts also count toward the limit.
  4. 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

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.