DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

3 Clocks for Password Reset Email API Requests and Timeouts — Explained

Short answer: treat a stalled password-reset email call as an uncertain handoff, record the attempt before the network call, and reconcile its delivery state before sending another message. The fastest request is not the useful metric; the useful metric is knowing which clock stopped.

A media subscription receipt after payment settles follows the same rule. The request may finish quickly while the recipient sees the receipt much later, or the client may give up even though the mail service accepted the work. Those are different events, and collapsing them into one send() result creates duplicate messages and bad support data.

This is an experiment note, not a provider comparison. Start with one message attempt, one stable identifier, and three timestamps. Then measure where the time went before changing an HTTP timeout.

The 3 clocks behind a delayed message

A transactional message has at least three clocks: application time, handoff time, and recipient-visible time. Application time begins when the password-reset or receipt workflow creates an attempt. Handoff time ends when the remote email API acknowledges that attempt. Recipient-visible time ends only when downstream evidence says the message reached the expected mailbox state; an API acknowledgment is not that evidence.

The simple approach is to await a request and label anything slow as a delayed send. It feels tidy, but it mixes a local queue, DNS or connection setup, a remote acknowledgment, and later mail delivery into one number. A timeout makes that ambiguity sharper: the caller knows it stopped waiting, not whether the remote system received the request before the connection ended.

For a media business, persist messageAttemptId, paymentId, message purpose, and the three timestamps beside the payment event. Use an opaque correlation ID in the outbound metadata if the email interface supports it. Never put a reset token or recipient address into logs; a correlation ID is enough to join traces without turning observability into a leak.

Short version: timeouts are observations, not verdicts.

Clock Starts Ends Useful question
Application Business event is stored Outbound attempt is created Did the app queue a single attempt?
Handoff Request begins Email API responds Did the remote API acknowledge it?
Recipient-visible API acknowledges Delivery evidence arrives Did the intended message become visible?

How should a Node.js password reset email request handle a hanging provider call?

Give the outbound request a deadline, create its idempotency record first, and make a later worker reconcile the uncertain result. Do not issue an immediate second send from the request handler, because the first call could have crossed the network boundary just before the deadline fired.

Here is a focused TypeScript shape. The endpoint is intentionally generic; the important contract is the local attempt record and the unknown state after an aborted wait. AbortSignal.timeout stops this process from waiting without pretending to know the remote outcome.

type AttemptState = "pending" | "accepted" | "unknown" | "rejected";

type MessageAttempt = {
  id: string;
  purpose: "password-reset" | "payment-receipt";
  state: AttemptState;
  createdAt: string;
};

async function requestEmail(attempt: MessageAttempt, payload: object) {
  await attempts.insert(attempt);

  try {
    const response = await fetch(process.env.MAIL_GATEWAY_URL!, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "idempotency-key": attempt.id
      },
      body: JSON.stringify({ ...payload, correlationId: attempt.id }),
      signal: AbortSignal.timeout(8_000)
    });

    await attempts.update(attempt.id, {
      state: response.ok ? "accepted" : "rejected",
      acknowledgedAt: new Date().toISOString()
    });
  } catch {
    await attempts.update(attempt.id, { state: "unknown" });
  }
}
Enter fullscreen mode Exit fullscreen mode

An HTTP 2xx belongs in accepted, while an explicit non-success response belongs in rejected. A timeout, connection close, or aborted fetch belongs in unknown until a reconciliation job determines otherwise. This state model works with Axios too: the transport library can change, but an interrupted client does not establish absence of remote acceptance.

The catch is operational ownership. Somebody must own reconciliation, retention, and an escalation path for attempts that remain unknown. A tiny application with no durable store or background worker should keep its existing mail path until it can make that promise; adding a short client timeout alone does not make the workflow safer.

Reconciliation prevents duplicate reset messages

The next run should query the message system using the correlation ID, or consume a signed delivery-status event where that interface exists. If the attempt was accepted, finalize the local record and do nothing else. If it was rejected, create a fresh attempt only after validating the reset flow is still valid. If evidence is absent, apply a documented policy: wait, expire the old reset token, or offer a human-support route. The right choice depends on the security model and the user impact, so your mileage may vary.

Write the reconciliation result as another event rather than overwriting the original timeout. That preserves the question a future investigator actually needs to answer: did the app stop waiting before the handoff finished, did the remote API decline the message, or did delivery take longer than the route budget? A payment receipt makes the distinction concrete. The payment event is already settled; the mail attempt is a separate side effect with its own idempotency key, timestamps, and evidence trail. If a worker sees an unknown attempt, it should look up that one stable key, record what it learned, and only then apply the business rule for a retry. This costs a little more design work than a catch-and-resend branch, but it prevents the support team from treating a request timeout as proof that nothing happened.

Pause there.

A password-reset message and a payment receipt deserve separate policies. A reset link can invalidate an older token and become a security concern if retry behavior is careless. A receipt is tied to a settled payment and may be retried after deduplication, but it must not accidentally become a second charge workflow. Keep the idempotency scope tied to the business event, not to the HTTP request object that happened to initiate it.

This also makes debugging boring in the best way. A support report can be answered with an attempt ID, a creation time, an acknowledgment time if present, and the final downstream state. No scavenger hunt through free-form logs.

What to measure before changing the timeout

Track distributions separately for local queue delay, outbound handoff duration, and time from acceptance to recipient-visible evidence. Add counts for pending, unknown, and duplicate-suppressed attempts. Percentiles help, but a raw count of unresolved attempts is often the first signal that the retry policy is creating work faster than reconciliation clears it.

For the payment receipt experiment, replay a settled-payment event with a fixed event ID, then force the client deadline in a non-production test environment. Confirm that exactly one durable attempt exists, that it reaches unknown rather than being declared failed, and that reconciliation either finds the acknowledged message or follows the written fallback. Repeat with a completed response and an explicit rejection. Those three cases expose most of the logic that a single happy-path test misses.

Don't optimize the eight-second value first. Set a deadline that fits the calling route's budget, then adjust it only after the three clocks show where latency accumulates.

Measure first. Change second.

A small boundary around message categories

Keep transactional traffic distinct from marketing traffic in code and data. Password resets and payment receipts have a direct user-triggered purpose; promotional mail has different consent and unsubscribe obligations. RFC 8058 defines a one-click unsubscribe mechanism for qualifying list mail, which is a useful reminder that message category changes the protocol and policy work around it.

SMS has a separate constraint: character encoding and segmentation change how a message is handled. The linked reference covers GSM-7 and UCS-2 limits, so do not reuse email-size assumptions if the fallback channel becomes SMS.

References

Further reading is limited to the two primary references above: they cover unsubscribe handling for list mail and SMS encoding limits, respectively.

Top comments (0)