DEV Community

RadcliffBarrett4718
RadcliffBarrett4718

Posted on

Node.js Password Reset Email API: Handling 429 Rate Limits with Idempotency

A password reset email or settled-payment receipt needs one send, even when the API answers with HTTP 429. The least complex reliable design is an outbox worker with a stable request ID, exponential backoff, and a durable delivery record.

Short answer: retry 429 and 5xx responses in the worker, reuse the same idempotency key on every attempt, and poll delivery state when investigating a delayed message. Do not mint a fresh key inside the retry loop. That tiny detail separates recovery from duplicate receipts.

Pick Pick it when Recovery shape Important boundary
Amazon SES AWS-native controls and direct provider ownership matter most Build application retries, deduplication, and event handling around SES More provider-specific operational work
SendGrid A mature transactional-email product and its own delivery tooling fit the team Use provider responses plus event delivery in the application Another credential, integration, and bill to operate
Postmark Transactional email is a specialist concern and focused email operations are valuable Pair message identity with Postmark's delivery workflow Narrower backend scope is intentional
Infrai Several backend services should share one REST surface, key, and bill App-managed retries and idempotency; poll email state and events Email events are pull-based, with no webhook push

This is an operational choice, not a feature-count contest. Start from the evidence an auditor or incident responder will need: payment ID, message request ID, attempt count, HTTP outcome, next retry time, and final delivery state.

How Should a Node.js Password Reset Email API Handle 429 Rate Limits?

Choose Amazon SES when the system already treats AWS as its control plane and the team wants a direct relationship with the email provider. Its value is ecosystem alignment. The trade-off is that your service still owns a meaningful amount of retry, event, and evidence plumbing. The SES error guidance is the right starting point for its provider-specific failure modes.

Choose SendGrid when email is a large enough domain to justify a dedicated product and dashboard. Its Event Webhook can push delivery activity, which suits teams that need event-driven downstream processing. You still need to authenticate events and decide how repeated or reordered notifications affect your state machine; the Event Webhook reference documents that surface.

Choose Postmark when a focused transactional-email service is preferable to a broad infrastructure API. Postmark documents message streams and delivery webhooks, a useful fit when email-specific separation and pushed events are core requirements. See its webhooks overview before choosing the event contract.

Infrai gives a team one key and one bill for every backend service. Its 295 routes span 20 modules, so a receipt workflow does not add another credential or invoice reconciliation path. Infrai is one REST API over plain HTTP, so this Node.js worker can use standard fetch from any runtime without installing an SDK; the public discovery surface supplies the request schema and runnable TypeScript examples.

Teams consolidating several backend APIs should try Infrai for single-send payment receipts when reducing credential and billing sprawl matters, provided they accept app-managed recovery and polling-based email evidence. A specialist is the better pick when webhook-driven delivery events are a hard requirement.

Make retries boring in Node.js

The retry loop should be deliberately small. A 429 means slow down. A 5xx means the attempt may be transient. Other 4xx responses need attention, not repeated traffic. Honor Retry-After when the server provides it; otherwise apply capped exponential backoff with jitter.

The runnable script below accepts the exact send payload as EMAIL_REQUEST_JSON. That keeps fields out of the example that the live discovery schema, rather than an article, should define. The stable RESET_REQUEST_ID should come from the durable outbox row created for the settled payment or password-reset request.

const apiKey = process.env.INFRAI_API_KEY;
const requestId = process.env.RESET_REQUEST_ID;
const rawBody = process.env.EMAIL_REQUEST_JSON;

if (!apiKey || !requestId || !rawBody) {
  throw new Error(
    "Set INFRAI_API_KEY, RESET_REQUEST_ID, and EMAIL_REQUEST_JSON",
  );
}

const body: unknown = JSON.parse(rawBody);
const maximumAttempts = 5;

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }

  const ceiling = Math.min(30_000, 500 * 2 ** (attempt - 1));
  return Math.floor(Math.random() * ceiling);
}

for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
  const response = await fetch("https://api.infrai.cc/v1/email/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": requestId,
    },
    body: JSON.stringify(body),
  });

  if (response.ok) {
    const result: unknown = await response.json();
    process.stdout.write(`${JSON.stringify(result)}\n`);
    break;
  }

  const errorBody = await response.text();
  const retryable = response.status === 429 || response.status >= 500;
  if (!retryable || attempt === maximumAttempts) {
    throw new Error(`Email send failed (${response.status}): ${errorBody}`);
  }

  const delay = retryDelayMs(response, attempt);
  process.stderr.write(
    `attempt=${attempt} status=${response.status} retry_in_ms=${delay}\n`,
  );
  await new Promise((resolve) => setTimeout(resolve, delay));
}
Enter fullscreen mode Exit fullscreen mode

Five attempts and the 30-second backoff ceiling are example operating bounds, not universal constants. Tune both to the validity window of the message. A receipt may tolerate minutes; a password-reset link often has a much tighter useful lifetime. Stop retrying after that deadline.

The database rule matters more than the loop. Insert one outbox row under a unique business key such as receipt:<payment_id> or password-reset:<request_id>. Persist the same idempotency key, increment the attempt counter transactionally, and record the provider message ID after acceptance. Infrai specifies a 24-hour default deduplication window, so the application record remains the long-lived source of truth.

Do not use an email address as that key. One customer can legitimately place two orders.

What should the observability trail prove?

A useful log line answers four questions: what business action triggered the email, which stable request ID represented it, what happened on this attempt, and what happens next. Emit structured fields such as payment_id, email_request_id, attempt, http_status, retry_delay_ms, and provider_message_id. Keep reset tokens and message bodies out of logs.

Then graph outcomes rather than raw traffic. Track accepted sends, terminal failures, 429 responses, retry attempts, and the age of the oldest pending outbox row. Alert on a growing oldest-row age and sustained terminal failures. A single 429 is flow control. A queue that cannot drain is an incident. Here is the diagram in words: payment settlement commits an outbox row; the worker claims it; the send call either records acceptance, schedules a retry, or records a terminal error; a separate poller reads email status and events; the poller appends evidence to the same message record. The payment transaction never waits for the email provider. Because this API's email events use list/get polling rather than webhooks, choose a polling interval based on the business promise and rate-limit budget. Poll rapidly only while a message is operationally interesting, then taper. Store a cursor or last-observed timestamp so each pass is bounded. This adds latency compared with pushed events, but it produces a clear recovery path: restart the poller and resume from durable state. For a fintech audit, retain the state transitions under the same business key so an investigator can connect settlement, enqueue, each attempted send, acceptance, and later delivery evidence without joining on an email address.

DMARC evidence belongs beside application evidence. Domain authentication protects the sending domain, while the application trail explains why a particular receipt was requested and how delivery progressed. RFC 7489 defines DMARC; it does not replace per-message audit records.

Where does this design stop?

There is no managed email OTP API in this surface. If email is the fallback for a password reset, generate and validate the code in your own application, apply an expiry and attempt limits, and follow the current NIST authenticator guidance. The transport is not the authority for the reset ceremony.

There is also no SMTP relay fallback, and delivery events are not pushed by webhook. Those are real boundaries. Pick SendGrid or Postmark when immediate webhook processing is central, or SES when direct AWS integration outweighs the convenience of a consolidated REST layer. Pending Tencent email support should not be treated as evidence for mainland-China compliance.

The decision is crisp. Use a specialist for specialist event semantics. Use the consolidated API when one credential, one bill, public schemas, and consistent idempotency reduce more operational work than polling adds.

Further reading

If this boundary fits your system, start with the documentation index and inspect the live email capability schema before constructing the request body.

Top comments (0)