429 on password-reset sends: backoff, idempotency and the cap you can read
Two different problems wear the same 429 costume. How to retry a reset email safely in Node 22, deduplicate sends, and read the daily cap before you hit it.
Two unrelated problems arrive dressed as the same 429. One is the provider throttling your account or your sender domain. The other is your own user tapping “resend” nine times while the first message is still in flight, which manufactures the first problem out of nothing. On Infrai the second one is entirely yours to fix, and fixing it removes most of the reason you’d ever meet the first.
Retrying a password reset also isn’t like retrying a GET. Each successful send mints a link, and if your token store invalidates the previous one, a blind retry can hand the user a working email that points at a dead token.
Retry semantics for a message that changes state
Think about what a duplicate actually costs before writing the loop. A repeated read is free and idempotent; a repeated send is a second billable email, a second entry in the recipient’s inbox, and — depending on how your reset tokens work — a race between two links. Password reset is the exact case where “just retry it” is wrong.
The rule that survives contact with production: retry the transport, never the intent. If the HTTP call failed in a way that means the provider never accepted the message, retry it. If the provider accepted it and something downstream went wrong, don’t send again — go and read what happened to the message you already have.
Branch on retryable, not on the number
Every error from the API comes back in an envelope with a machine-readable code and a boolean retryable, and those two fields carry more information than the status line. Here’s what the send route actually answers in the cases that matter:
| Situation | code | HTTP | retryable | What to do |
|---|---|---|---|---|
| Recipient string isn’t an address | VENDOR_DOWN | 503 | true | Do not retry — fix the input |
| Account not entitled to a custom sender | PRO_REQUIRED | 402 | false | Upgrade or drop the custom from |
| Sender domain not verified | VENDOR_NOT_CONFIGURED | 503 | false | Publish DNS, then send |
| Account or user throttle | RATE_LIMIT_ACCOUNT / RATE_LIMIT_USER | 429 | true | Back off with jitter |
| Upstream provider throttle | RATE_LIMIT_VENDOR | 429 | true | Back off; consider a queue |
Note the first row, because it’s a trap. A malformed recipient currently surfaces as a 503-class body with retryable: true, and retrying it forever is exactly what a naive wrapper will do. Treat retryable as a hint that needs a code allow-list on top of it, not as a command. Worth flagging: there’s no Retry-After header on these responses today, so the backoff schedule is yours to choose rather than the server’s to dictate.
You can see the envelope for yourself with a deliberately broken call:
curl -s -X POST https://api.infrai.cc/v1/email/send \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{
"to": "not-an-address",
"from": "security@example.com",
"subject": "Reset your password",
"html": "<p>This link expires in 30 minutes.</p>"
}'
{
"ok": false,
"error": {
"code": "VENDOR_DOWN",
"http_status": 503,
"message": "invalid recipient address: 'not-an-address'",
"retryable": true,
"trace_id": "trc_40e5b86fbbd04a9c95b065e3",
"request_id": "req_055714b863d44e739e169756"
}
}
Keep trace_id in your logs. It’s the fastest way to ask a question about one specific attempt out of a burst of forty.
Read the cap instead of discovering it
Sender domains carry a daily cap that grows as the domain warms up, and both the cap and today’s consumption are readable for free. Checking it costs a request and tells you whether the throttle you’re about to meet is a rate limit or a volume ceiling.
curl -s https://api.infrai.cc/v1/email/domain/get/example.com \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
"ok": true,
"data": {
"reputation": {
"domain": "example.com",
"tier": "warming_up",
"current_daily_cap": 50000,
"used_today": 0,
"bounce_rate_30d": 0.0,
"days_in_current_tier": 0,
"throttle_risk": "low",
"measured_at": "2026-07-26T00:38:37.900205Z"
}
}
}
throttle_risk is the field to alert on. If it climbs while used_today approaches current_daily_cap, no client-side backoff will save you — you need to shed load or spread it across the day.
A reset sender that retries transport and nothing else
The script below does four things: it derives a dedupe key from the recipient and the token so two identical intents within the cooldown window collapse into one, it retries only on an allow-listed set of codes, it uses full jitter on an exponential schedule, and it gives up loudly instead of quietly.
// reset-sender.mjs — Node 22, no dependencies
import { createHash } from "node:crypto";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const RETRYABLE_CODES = new Set([
"RATE_LIMIT_ACCOUNT",
"RATE_LIMIT_USER",
"RATE_LIMIT_VENDOR",
"VENDOR_TIMEOUT",
]);
const COOLDOWN_MS = 60_000;
const recentSends = new Map(); // dedupeKey -> { at, messageId }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const dedupeKey = (to, token) =>
createHash("sha256").update(`${to}|${token}`).digest("hex").slice(0, 32);
async function postSend(body) {
const res = await fetch(`${API}/v1/email/send`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await res.json().catch(() => ({}));
if (res.ok && payload.ok !== false) return { ok: true, data: payload.data };
const code = payload.error?.code ?? `HTTP_${res.status}`;
return { ok: false, code, message: payload.error?.message ?? res.statusText };
}
export async function sendReset({ to, token, attempts = 4 }) {
const key = dedupeKey(to, token);
const seen = recentSends.get(key);
if (seen && Date.now() - seen.at < COOLDOWN_MS) {
return { deduped: true, messageId: seen.messageId };
}
let backoff = 500;
for (let attempt = 1; attempt <= attempts; attempt++) {
const out = await postSend({
to,
from: "security@example.com",
subject: "Reset your password",
html: `<p>This link expires in 30 minutes: <a href="https://app.example.com/r/${token}">reset</a></p>`,
});
if (out.ok) {
recentSends.set(key, { at: Date.now(), messageId: out.data.message_id });
return { deduped: false, messageId: out.data.message_id };
}
if (!RETRYABLE_CODES.has(out.code) || attempt === attempts) {
throw new Error(`reset send failed (${out.code}): ${out.message}`);
}
const wait = Math.floor(Math.random() * backoff); // full jitter
console.warn(`attempt ${attempt} hit ${out.code}, sleeping ${wait}ms`);
await sleep(wait);
backoff = Math.min(backoff * 2, 8000);
}
throw new Error("unreachable");
}
const result = await sendReset({
to: process.argv[2] ?? "dana@example.com",
token: process.argv[3] ?? "tok_2f9a1c",
});
console.log(JSON.stringify(result));
Full jitter matters more than the base delay. A fixed doubling schedule synchronises every worker that got throttled in the same second, and they all come back together — the retry storm you were trying to avoid. Randomising the whole interval spreads them.
Confirm you didn’t double-send
After a retry storm the honest question is how many messages actually left. The message archive answers it directly, and reading it is free:
curl -s "https://api.infrai.cc/v1/email/list?limit=5" \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
"ok": true,
"data": {
"items": [
{ "message_id": "msg_9xKcQ2mRt4Vb7NpLd0Ef1Zqa", "state": "sent", "channel": "email", "to": "dana@example.com", "vendor": "resend", "created_at": 1785025802.378 }
],
"next_cursor": null,
"count": 1
}
}
Two rows with the same recipient a second apart means your dedupe key isn’t doing its job, usually because the token changed between attempts.
The limit that matters is the one you impose
Provider throttles are a symptom. The cure is a per-user cooldown on your own reset endpoint — one reset email per address per 60 seconds, with the same response returned whether or not a send happened, so the endpoint doesn’t leak which addresses exist. That single rule removes the overwhelming majority of 429s, and it’s the one thing no vendor can do for you.
For genuinely high fan-out, POST /v1/email/batch/send takes up to 100 messages behind a single idempotency key, which is a better shape than 100 individual calls racing each other. Past that ceiling you’ll get EMAIL_BATCH_TOO_LARGE, and chunking is on you.
Where other providers differ
Brevo documents per-plan API rate limits and publishes guidance on throttling behaviour, which is useful reading even if you’re not a customer; Postmark and Resend expose similar quotas in their dashboards. If your app sends nothing but mail and you want a vendor whose limits are published as hard numbers per plan, you’d be better off with one of them than with a multi-service platform. The trade-off Infrai offers is the other direction: the reset email, the queue you defer it to, the cron job that expires the token and the error you capture when the send fails all sit behind one key, so there’s one place to look when the 429 shows up and one bill at the end of it.