{"record":{"id":"5cbca9f34fc0e181","repo":"koala73/worldmonitor","slug":"rate-limited","errorCode":"rate_limited","errorMessage":"Too many recent submissions for this email; try again later.","messagePattern":"Too many recent submissions for this email; try again later\\.","errorType":"exception","errorClass":"ConvexError","httpStatus":null,"severity":"warning","filePath":"convex/contactMessages.ts","lineNumber":91,"sourceCode":"        kind: \"FREE_EMAIL_NOT_ALLOWED\",\n        message: \"Please use a corporate email address.\",\n      });\n    }\n\n    const normalizedEmail = email.toLowerCase();\n\n    // Throttle: cap recent submissions per email. Index lookup keeps this O(matches),\n    // which the limit caps at PER_EMAIL_LIMIT + 1.\n    const windowStart = Date.now() - PER_EMAIL_WINDOW_MS;\n    const recent = await ctx.db\n      .query(\"contactMessages\")\n      .withIndex(\"by_normalized_email_received\", (q) =>\n        q.eq(\"normalizedEmail\", normalizedEmail).gte(\"receivedAt\", windowStart),\n      )\n      .take(PER_EMAIL_LIMIT + 1);\n\n    if (recent.length >= PER_EMAIL_LIMIT) {\n      throw new ConvexError({\n        kind: \"rate_limited\",\n        message: \"Too many recent submissions for this email; try again later.\",\n      });\n    }\n\n    await ctx.db.insert(\"contactMessages\", {\n      name,\n      email,\n      organization,\n      phone,\n      message,\n      source,\n      receivedAt: Date.now(),\n      normalizedEmail,\n    });\n    return { status: \"sent\" as const };\n  },\n});","sourceCodeStart":73,"sourceCodeEnd":109,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/contactMessages.ts#L73-L109","documentation":"Thrown by `submit` when the `by_normalized_email_received` index returns `>= PER_EMAIL_LIMIT` (5) rows for the same lowercased email within the trailing `PER_EMAIL_WINDOW_MS` (1 hour). This is a per-email (not per-IP) throttle because Convex mutations have no request IP. Carries object data `{ kind: \"rate_limited\", message: \"...\" }`; branch on `err.data.kind === \"rate_limited\"`. The check uses `.take(6)` so it is O(matches), bounded, and index-backed.","triggerScenarios":"The same normalized email submits the contact form 6 or more times within 60 minutes. Each successful insert increments the count; the 6th call within the window reads 5 prior rows and throws. Triggered by a user repeatedly clicking submit, a retry loop that doesn't back off, or a low-effort DoS that doesn't rotate emails.","commonSituations":"User double/triple-clicks the submit button and each click fires a separate mutation; a flaky network triggers client-side retries; an automated smoke test hammers the same address; an impatient user re-submits edits. Note: the limit is per normalized email, so a single real user hitting it from one address is the typical cause — a rotating-email spammer evades this but hits the edge-side Turnstile/free-domain block.","solutions":["Wait at least 1 hour (PER_EMAIL_WINDOW_MS) before retrying with the same email.","Disable the submit button after first successful send; show a success state so the user doesn't re-submit.","On `err.data.kind === \"rate_limited\"`, surface a friendly \"try again later\" message and start a client-side countdown — do NOT auto-retry.","If the user genuinely needs to re-send (e.g. corrected a field), use a different corporate email or wait for the window to roll."],"exampleFix":"// before\nconst send = () => convex.mutation(api.contactMessages.submit, { ... });\nbutton.onclick = send; // double-click = 2 mutations\n\n// after — guard against rapid re-submission and the server rate limit\nlet lastSentAt = 0;\nbutton.onclick = async () => {\n  if (Date.now() - lastSentAt < 60_000) return; // local 1-min cooldown\n  try {\n    await convex.mutation(api.contactMessages.submit, { ... });\n    lastSentAt = Date.now();\n    button.disabled = true;\n  } catch (err) {\n    if (err.data?.kind === \"rate_limited\") {\n      showToast(\"You've sent several messages recently. Please try again in an hour.\");\n    } else throw err;\n  }\n};","handlingStrategy":"retry","validationCode":"const PER_EMAIL_WINDOW_MS = 60 * 60 * 1000;\nconst PER_EMAIL_LIMIT = 5;\nfunction canResubmit(lastSentAt: number, recentCount: number): boolean {\n  return Date.now() - lastSentAt >= PER_EMAIL_WINDOW_MS || recentCount < PER_EMAIL_LIMIT;\n}","typeGuard":"function isRateLimitedError(err: unknown): boolean {\n  return typeof err === \"object\" && err !== null && \"data\" in err && (err as any).data?.kind === \"rate_limited\";\n}","tryCatchPattern":"try {\n  await convex.mutation(api.contactMessages.submit, { ... });\n} catch (err) {\n  if (err.data?.kind === \"rate_limited\") {\n    showToast(\"Too many submissions. Try again in an hour.\");\n    // do NOT auto-retry\n  } else throw err;\n}","preventionTips":["Disable the submit button after a successful send.","Add a client-side cooldown longer than the network round-trip.","Track last-sent timestamp locally and block re-submission within the window.","Never auto-retry on rate_limited — surface a wait message instead."],"tags":["rate-limit","throttle","convex","contact-form"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}