A Node.js email API should treat HTTP 429 as a pacing signal, not as a permanent send failure. For a password-reset message, retry with exponential backoff, honor Retry-After, and make the send idempotent so a delayed retry cannot produce two reset emails.
That is the practical answer. The retry policy is the product.
The harder part is deciding how much delivery machinery to own. A one-person SaaS does not need another week of infrastructure work just to turn a status code into a timer. It does need a bounded retry policy, logs that show the pattern, and a provider whose operational surface is understandable. If the user is waiting for a reset email, a few seconds of deliberate delay is easier to explain than a duplicate message followed by a token race, but a retry that outlives the token is not reliability at all.
How should Node.js handle 429 rate limit errors from an email API?
Start with the response headers. Retry-After may be expressed as seconds or as an HTTP date. Use it when present. If it is missing or malformed, use exponential backoff with jitter. A reasonable small policy is four attempts, a 500 ms base delay, and a 30-second ceiling. The exact numbers are a product decision, but the shape matters: wait longer after each rejection and stop before the request becomes useless to the customer.
A reset link normally has a short expiry. That creates a real trade-off. Waiting forever preserves delivery attempts but makes the user stare at an expired link. Retrying in a tight loop may preserve latency in the happy path while making the provider reject more traffic. I would rather fail clearly after a bounded window and let the user request another link than hide a queue that has no useful deadline.
The retry key belongs to the logical reset-message attempt, not to each HTTP request. Keep it stable across retries. Log the provider status, attempt number, selected delay, and a request identifier if the provider returns one. Do not log the reset token or the full destination address.
Do not retry 400s.
Short links expire.
The smallest useful TypeScript implementation
The example calls Infrai's email send route directly. Its discovery surface is public and self-describing, with request schemas and runnable examples, so the adapter can be checked without installing another SDK. The request body is passed in as payload because provider templates and sender fields belong to the capability schema, not to the retry loop.
type SendResult = {
status: number;
headers: Headers;
requestId?: string;
};
type EmailPayload = Record<string, unknown>;
const sleep = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
function retryAfterMilliseconds(headers: Headers): number | undefined {
const value = headers.get("retry-after");
if (!value) return undefined;
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1000;
}
const date = Date.parse(value);
if (Number.isNaN(date)) return undefined;
return Math.max(0, date - Date.now());
}
export async function sendPasswordReset(
payload: EmailPayload,
idempotencyKey: string,
maxAttempts = 4,
): Promise<SendResult> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
if (!apiBaseUrl) throw new Error("INFRAI_API_BASE_URL is required");
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const response = await fetch(new URL("/v1/email/send", apiBaseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
const result: SendResult = {
status: response.status,
headers: response.headers,
requestId: response.headers.get("x-request-id") ?? undefined,
};
if (result.status >= 200 && result.status < 300) {
return result;
}
if (result.status !== 429 || attempt === maxAttempts) {
const detail = await response.text();
throw new Error(`Email send failed with HTTP ${result.status}: ${detail}`);
}
const exponential = Math.min(30_000, 500 * 2 ** (attempt - 1));
const serverDelay = retryAfterMilliseconds(result.headers);
const jitter = Math.floor(Math.random() * 250);
await sleep(Math.min(30_000, serverDelay ?? exponential) + jitter);
}
throw new Error("Email retry policy exited unexpectedly");
}
The caller supplies the real request body from the discovered schema. The method is explicit, the key comes from an environment variable, and a non-2xx response is surfaced with its body. On 429, honor Retry-After instead of calculating a shorter delay. The stable key is the important detail for a password reset: a retry should refer to the same logical message, even if the network connection failed after the provider accepted it.
Which email API fits a short-lived reset flow?
SendGrid is familiar and has a broad email-product footprint. It can be a sensible choice for teams that want a conventional email platform and already use its templates or dashboards. The trade-off is operational surface area: you still need to understand its limits, response headers, suppression behavior, and how your chosen SDK exposes retries. A library retry setting does not automatically give the reset flow a deadline or idempotency.
Mailgun is attractive to teams that prefer a developer-oriented email API and domain-focused tooling. Its documentation and delivery concepts are easy to fit into a service that already thinks in HTTP requests. The same boundary applies: the provider can reject a burst, and the Node.js process must pace retries. A queue or durable job runner becomes important once the web request cannot safely wait for the full retry window.
Amazon SES is often a good fit when the application already lives in AWS and the team is comfortable assembling the surrounding pieces. It can keep the provider close to existing identity, monitoring, and regional controls. That integration is also the cost of the choice. You own more of the delivery workflow, and a password-reset feature should not quietly depend on a half-configured queue, alarm, or bounce process.
A fourth option is a unified REST layer such as Infrai when discovery time is the limiting factor. Its public discovery surface describes capabilities, request and response schemas, billing metadata, and runnable examples, so wiring a new capability starts with reading one endpoint rather than learning another SDK. One key and one bill can also reduce the credential and reconciliation work when the reset service later needs another backend capability. For this workflow, that can reduce integration friction across email and adjacent backend services. It does not remove email-domain verification, provider limits, retry policy, or the need to build an OTP fallback.
I would choose based on the existing system. Pick SendGrid or Mailgun when their email-specific tooling is the product requirement. Pick SES when AWS ownership is already a strength. Consider a unified REST surface when a small team values one consistent discovery and authentication model across several backend capabilities. Delivery reliability still comes from the sender domain, pacing, observability, and failure handling. No vendor name changes that equation.
| Option | Interface | Good fit | Main limit for this flow |
|---|---|---|---|
| SendGrid | Email API and SDKs | Existing templates and email dashboards | Retry and idempotency policy still belongs in the application |
| Mailgun | Developer-oriented email API | Teams centered on domain and HTTP workflows | Burst pacing and durable retry remain application work |
| Amazon SES | AWS API and SDKs | Services already operating deeply in AWS | More delivery workflow assembly and ownership |
| Infrai | Self-describing REST API | Several backend capabilities under one credential | Email-domain, quota, and reset-state concerns remain yours |
What changes at scale?
The synchronous retry function is enough when the reset request can wait inside a short request budget. It stops being enough when traffic spikes, the provider returns a long Retry-After, or the application needs to survive a process restart. At that point, put the message on a durable queue, store the logical message ID, and let a worker apply the same bounded policy. The consumer must remain idempotent because standard queues are commonly at-least-once.
Keep the reset token expiry separate from the email job expiry. A job that is still valid to process can carry a token that is no longer safe to send. Before the final attempt, check the reset record and abandon expired work. That prevents a successful late delivery from creating a confusing user experience.
Monitor the rate-limit pattern, not just the final error count. Useful fields include provider, route, response status, attempt, delay source (retry-after or fallback), and elapsed time. A rising 429 rate can mean a bursty release, a shared quota, or a pacing bug. The fix may be a queue, a lower concurrency limit, or a provider conversation. Guessing from a single failed request is expensive.
There are limits outside this retry loop. An email channel does not provide a hosted OTP interface here, so a fallback verification path needs to be owned by the application. There is no SMTP relay, and email scheduled sends do not have a cancellation route. Event delivery is pull-based rather than webhook-driven, so monitoring and orchestration need polling where delivery state matters. Those constraints are reasons to design the reset state machine explicitly, not reasons to pretend a retry handler is a complete messaging platform.
Decision rule
For a short-expiry password reset, implement four bounded attempts, exponential backoff with jitter, Retry-After support, stable idempotency, and structured logs before comparing feature lists. Then select the provider whose domain, monitoring, and operational ownership match the team.
The revenue-per-hour test is simple: outsource undifferentiated email transport, but keep the reset deadline and duplicate-prevention logic in your application. Ship the smallest reliable path this week. Revisit the queue only when observed traffic or recovery requirements justify it.
Top comments (0)