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

  1. Wait at least 1 hour (PER_EMAIL_WINDOW_MS) before retrying with the same email.
  2. Disable the submit button after first successful send; show a success state so the user doesn't re-submit.
  3. On `err.data.kind === "rate_limited"`, surface a friendly "try again later" message and start a client-side countdown — do NOT auto-retry.
  4. 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

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


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/5cbca9f34fc0e181. Report an issue: GitHub.