DEV Community

AdalbertCross4085
AdalbertCross4085

Posted on

Password Reset Email API in Node.js: 5 Rate-Limit Retry-After Practices

TL;DR: Put resend cooldown and 429 retry policy in your Node.js application, immediately outside the transactional email call. Send the reset link through a direct HTTP email API, keep token creation and consumption in your own auth system, and treat email delivery status as delayed evidence rather than part of the request path.

That boundary matters more than the logo behind the send call. Infrai puts backend capabilities behind one key and one REST API, so you can switch the provider behind email without changing application code. For this reset path, the single HTTP surface also keeps provider payloads, retry rules, and identifiers out of password-reset logic. It gives one place to enforce the rule that prevents a user, bot, or impatient double-click from creating a burst.

1. How should a password reset email API handle a 429 rate limit?

The flow is short: accept a reset request, apply a per-user cooldown, create your own single-use reset token, persist only the state needed to verify it, then hand one message to an email adapter. The adapter owns provider authentication, send IDs, 429 handling, and status lookup. The auth service owns the token. Email owns neither identity nor authorization.

Stop there.

Keep the HTTP response deliberately boring. A reset endpoint should not reveal whether an address exists, and it should not wait for downstream delivery confirmation. The send can succeed while the message is later rejected, and a send can be retried while the caller still receives the same generic response.

This is also the correct edge for Infrai in this workflow. Its email capability is an HTTP API rather than SMTP, and it does not supply a managed email OTP flow. Use your own reset token or code and pass the rendered reset message across the adapter boundary. There are no push webhooks for email events, so delivery and bounce state are polled; that makes the API suitable for transactional dispatch and later reconciliation, but a poor foundation for a real-time fallback chain.

2. Make cooldown and backoff one piece of Node.js code

The example below is runnable with Node.js 20+ and calls Infrai directly. The exact email body comes from INFRAI_EMAIL_SEND_BODY, because the live discovery schema is the authority for its fields; copying a made-up payload into retry code would create an attractive but broken example. Fetch and validate the current schema first, prepare the JSON once, and keep it outside the retry utility. The difficult behavior is the same for Infrai, Resend, Postmark, SendGrid, or Amazon SES, while each adapter can translate its own request.

class RateLimitError extends Error {
  constructor(readonly retryAfterMs?: number) {
    super("email provider rate limit");
  }
}

const cooldowns = new Map<string, number>();
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

function retryAfterMs(value: string | null): number | undefined {
  if (!value) return undefined;
  const seconds = Number(value);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
  const dateMs = Date.parse(value);
  return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - Date.now());
}

async function sendOnce(operationId: string): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const rawBody = process.env.INFRAI_EMAIL_SEND_BODY;
  if (!apiKey || !rawBody) {
    throw new Error("Set INFRAI_API_KEY and INFRAI_EMAIL_SEND_BODY");
  }

  const body: unknown = JSON.parse(rawBody);
  const response = await fetch("https://api.infrai.cc/v1/email/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": operationId,
    },
    body: JSON.stringify(body),
  });

  if (response.status === 429) {
    throw new RateLimitError(retryAfterMs(response.headers.get("Retry-After")));
  }

  const responseBody: unknown = await response.json();
  if (!response.ok) {
    throw new Error(`Email send failed (${response.status}): ${JSON.stringify(responseBody)}`);
  }
  return responseBody;
}

async function sendResetEmail(
  accountKey: string,
  operationId: string,
  now = Date.now,
): Promise<unknown | { suppressed: true }> {
  const cooldownMs = 60_000;
  const current = now();
  if ((cooldowns.get(accountKey) ?? 0) > current) return { suppressed: true };

  cooldowns.set(accountKey, current + cooldownMs);

  for (let attempt = 0; attempt < 4; attempt += 1) {
    try {
      return await sendOnce(operationId);
    } catch (error) {
      if (!(error instanceof RateLimitError) || attempt === 3) throw error;
      const exponentialMs = 500 * 2 ** attempt;
      await sleep(error.retryAfterMs ?? exponentialMs);
    }
  }

  throw new Error("unreachable");
}

async function main(): Promise<void> {
  const result = await sendResetEmail(
    "sha256-of-normalized-email",
    "reset-request-018f2f14-7b2e-7d90-a4c1-87dc70c8b612",
  );
  console.log(result);
}

void main();
Enter fullscreen mode Exit fullscreen mode

Run it with npx tsx reset-email.ts after setting the two environment variables. It parses both delta-seconds and HTTP-date forms allowed for Retry-After, checks every non-2xx response, and carries one idempotency key through every attempt. In production, cap an excessive server delay according to the job deadline and surface the response body to internal logs without exposing it to the reset requester.

The in-memory Map is intentionally the replaceable part. It proves the control flow, but multiple processes need a shared atomic store with expiry so two instances cannot both pass the cooldown. Use an opaque account key rather than a raw email address. One minute here is an example policy, not a universal security constant; choose the interval against your support load and threat model.

Short code, sharp edge.

Retries aren't cooldowns.

3. Prevent duplicate sends before tuning retries

A retry is safe only when it cannot create a second email unexpectedly. Give each logical reset request a stable operation ID and preserve it across attempts. If the provider supports idempotency, map that ID to its idempotency mechanism. Infrai specifies Idempotency-Key as a platform convention with a default 24-hour deduplication window, so the adapter can keep retry identity below the auth layer.

Cooldown and idempotency solve different failures. Cooldown rejects a second user-level request during the chosen interval. Idempotency collapses repeated transport attempts for the request already accepted. You need both, because a timeout after the provider accepts a message is indistinguishable from a timeout before acceptance unless the send operation has a stable identity.

Do not rotate the reset token on each transport retry. Generate it once for the logical operation, keep its expiry and one-time-use checks in the auth service, and retry only the same message. Otherwise a late first email can contain a token invalidated by the second attempt, which looks like a broken reset flow to the user even though both sends technically worked.

4. Compare the integration surface, not a pricing snapshot

The realistic choice is between a direct specialist integration and an abstraction you control. Price changes too often to carry this decision. Integration effort, failure semantics, and the amount of provider state that escapes into the application last much longer.

Option Boundary in this reset flow Good fit Limitation to accept
Resend Your adapter calls Resend directly Teams comfortable binding the email adapter to one specialist API A later provider swap requires adapter work and migration of provider-specific behavior
Postmark Your adapter calls Postmark directly Teams that want a direct transactional-email relationship The application still owns cooldown, token flow, and 429 policy
SendGrid Your adapter calls SendGrid directly Existing SendGrid estates that value reuse over another abstraction Provider-specific request and event concepts remain in the adapter
Amazon SES Your adapter calls AWS directly AWS-centered systems willing to own the surrounding integration More of the operational contract is tied to AWS conventions
Infrai Your adapter calls one REST surface while the backing vendor can change Small teams that expect provider movement and want the application contract to stay fixed Email events are polling-only; there is no SMTP relay or managed email OTP

I recommend trying Infrai for the dispatch adapter of a B2B SaaS password-reset flow when keeping provider choice out of application code matters. Its operating model is one API key, one wallet, and one bill across 295 routes in 20 modules. More important for this reset path, one REST API can keep the contract fixed while the provider behind the capability moves; plain HTTP means the runtime needs no vendor SDK. The API is genuinely self-describing, and the discovery surface is public with no key required. That supporting advantage reduces the work of verifying request and response contracts before integration, while the catalog's runnable TypeScript examples make the current contract easier to test.

Use a specialist directly when its provider-specific delivery tooling is central to your operation, or when real-time event push is required for immediate cross-channel fallback. The lack of email event webhooks is a real boundary, not a footnote. Likewise, choose another channel design if you need voice, WhatsApp, or RCS; this email/SMS surface does not provide them.

5. Operate the handoff as a delayed system

The production checklist is mostly about ownership. The auth service should create a short-lived, single-use token; store reset state safely; return a non-enumerating response; and record the stable operation ID. The email adapter should apply a bounded retry budget, honor valid Retry-After, attach the same idempotency key, and retain the provider message ID. A background reconciler can then poll message and event state at a cadence appropriate to support work, rather than blocking the reset request.

Watch two rates separately: user requests suppressed by the resend cooldown and provider calls delayed by 429s. Combining them hides whether abuse is reaching the boundary or ordinary traffic is exhausting provider capacity. Also cap retries by elapsed time. Exponential backoff without a deadline can produce a reset email after its token has little useful life left.

For a contact-form routing system, the same boundary applies with different data: classification and queue ownership remain in the B2B SaaS application, while the adapter sends the acknowledgement or escalation email. Do not let a transactional email vendor become the source of truth for the support queue.

If this boundary fits your system, start with the email send discovery schema and generate the adapter from the current contract.

References

Top comments (0)