koala73/worldmonitor · warning · ConvexError
rate_limited
rate_limited
Error message
Too many recent submissions for this email; try again later.
What it means
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.
Source
Thrown at convex/contactMessages.ts:91
kind: "FREE_EMAIL_NOT_ALLOWED",
message: "Please use a corporate email address.",
});
}
const normalizedEmail = email.toLowerCase();
// Throttle: cap recent submissions per email. Index lookup keeps this O(matches),
// which the limit caps at PER_EMAIL_LIMIT + 1.
const windowStart = Date.now() - PER_EMAIL_WINDOW_MS;
const recent = await ctx.db
.query("contactMessages")
.withIndex("by_normalized_email_received", (q) =>
q.eq("normalizedEmail", normalizedEmail).gte("receivedAt", windowStart),
)
.take(PER_EMAIL_LIMIT + 1);
if (recent.length >= PER_EMAIL_LIMIT) {
throw new ConvexError({
kind: "rate_limited",
message: "Too many recent submissions for this email; try again later.",
});
}
await ctx.db.insert("contactMessages", {
name,
email,
organization,
phone,
message,
source,
receivedAt: Date.now(),
normalizedEmail,
});
return { status: "sent" as const };
},
});View on GitHub (pinned to ffec79ac33)
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.
Example fix
// before
const send = () => convex.mutation(api.contactMessages.submit, { ... });
button.onclick = send; // double-click = 2 mutations
// after — guard against rapid re-submission and the server rate limit
let lastSentAt = 0;
button.onclick = async () => {
if (Date.now() - lastSentAt < 60_000) return; // local 1-min cooldown
try {
await convex.mutation(api.contactMessages.submit, { ... });
lastSentAt = Date.now();
button.disabled = true;
} catch (err) {
if (err.data?.kind === "rate_limited") {
showToast("You've sent several messages recently. Please try again in an hour.");
} else throw err;
}
}; Defensive patterns
Strategy: retry
Validate before calling
const PER_EMAIL_WINDOW_MS = 60 * 60 * 1000;
const PER_EMAIL_LIMIT = 5;
function canResubmit(lastSentAt: number, recentCount: number): boolean {
return Date.now() - lastSentAt >= PER_EMAIL_WINDOW_MS || recentCount < PER_EMAIL_LIMIT;
} Type guard
function isRateLimitedError(err: unknown): boolean {
return typeof err === "object" && err !== null && "data" in err && (err as any).data?.kind === "rate_limited";
} Try / catch
try {
await convex.mutation(api.contactMessages.submit, { ... });
} catch (err) {
if (err.data?.kind === "rate_limited") {
showToast("Too many submissions. Try again in an hour.");
// do NOT auto-retry
} else throw err;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Valid email is required
- PRO_REQUIRED
- INCOMPATIBLE_DELIVERY
- COUNTRIES_LIMIT_EXCEEDED
- TICKERS_LIMIT_EXCEEDED
AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12).
Data as JSON: /api/errors/5cbca9f34fc0e181.
Report an issue: GitHub.