{"record":{"id":"ef324e262e9a0445","repo":"docusealco/docuseal","slug":"ratelimit-limitapproached","errorCode":null,"errorMessage":"RateLimit::LimitApproached","messagePattern":"RateLimit::LimitApproached","errorType":"exception","errorClass":"RateLimit::LimitApproached","httpStatus":429,"severity":"warning","filePath":"lib/rate_limit.rb","lineNumber":15,"sourceCode":"# frozen_string_literal: true\n\nmodule RateLimit\n  LimitApproached = Class.new(StandardError)\n\n  STORE = ActiveSupport::Cache::MemoryStore.new\n\n  module_function\n\n  def call(key, limit:, ttl:, enabled: Docuseal.multitenant?)\n    return true unless enabled\n\n    value = STORE.increment(key, 1, expires_in: ttl)\n\n    raise LimitApproached if value > limit\n\n    true\n  end\nend\n","sourceCodeStart":1,"sourceCodeEnd":20,"githubUrl":"https://github.com/docusealco/docuseal/blob/004a22c1c88109c7ba0b567df011a8cb13894001/lib/rate_limit.rb#L1-L20","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"# before\nRateLimit.call(\"send-otp-code-#{ip}\", limit: 2, ttl: 45.seconds, enabled: true)\n\n# after\nbegin\n  RateLimit.call(\"send-otp-code-#{ip}\", limit: 2, ttl: 45.seconds, enabled: true)\nrescue RateLimit::LimitApproached\n  render json: { error: 'Too many attempts. Try again in 45 seconds.' }, status: :too_many_requests\nend","handlingStrategy":"retry","validationCode":"# Pre-check remaining budget when the store is reachable\nremaining = limit - (RateLimit::STORE.read(key).to_i || 0)\nraise RateLimit::LimitApproached if remaining <= 0","typeGuard":null,"tryCatchPattern":"begin\n  RateLimit.call(key, limit: 2, ttl: 45.seconds, enabled: true)\nrescue RateLimit::LimitApproached\n  sleep(45) # or schedule a job at ttl expiry\n  retry\nend","preventionTips":["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."],"tags":["rate-limit","throttling","otp","cache"],"backgroundTag":"rate-limit-exceeded","analyzedSha":"004a22c1c88109c7ba0b567df011a8cb13894001","analyzedAt":"2026-08-21T13:38:23.343Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}