Short answer: When a Node.js password reset email API returns 429, retry the same durable operation after Retry-After; do not generate a new token or treat an in-process backoff timer as an audit record. Keep one logical delivery ID, store every send attempt separately, and persist the next attempt time before the worker exits.
I have been paged by missed jobs and duplicate deliveries in cron and queue systems. I first treated those as opposite failure classes: either the scheduler lost work or an overeager retry repeated it. The operational record showed the shared cause. We had modeled worker executions, not the uncertain boundary between our worker and a remote receiver. In an edtech setting, the painful case is bounded but serious: a learner requests account recovery near an assessment deadline, the email transport throttles the send, and support later needs to establish what the system attempted without exposing the recovery secret. One recovery intent needs one stable operation ID, while it may have several transport attempts. That is the invariant.
The trade-off is extra state.
This matters more than shaving a few milliseconds from the endpoint. It also changes where integration effort belongs. Put it into the queue boundary, state transitions, and evidence schema, not into scattered setTimeout calls inside web handlers.
How should a password reset email API handle a 429 rate limit?
HTTP 429 means the client sent too many requests in a given amount of time. RFC 6585 says the response may include Retry-After; RFC 9110 defines that field as either a delay in seconds or an HTTP date. A throttled attempt therefore has a known result: the provider did not accept that attempt as successful. It does not prove that an earlier timed-out attempt failed, and it says nothing about inbox placement.
That distinction is where duplicate mail usually enters. A worker can lose the response after a remote system accepted a request. No response arrives. If the job is then redelivered, an at-least-once queue does exactly what it promises and runs the handler again. The queue's job ID can deduplicate queue insertion, but it cannot retroactively establish what a remote email system accepted. Queue deduplication and send idempotency are separate controls.
That is the trap.
For a password-reset flow, keep the public response uniform and avoid changing timing based on whether an account exists. OWASP recommends a consistent message and response time to reduce account enumeration, and it recommends single-use, expiring reset tokens. NIST SP 800-63B also requires rate limiting for failed authentication attempts, though its exact authentication controls should not be misrepresented as transport retry policy. Application abuse limits and provider 429 handling solve different problems.
Model one intent and many attempts
The audit model should survive a transport migration. I use four records: the recovery intent, a secret reference or digest, a logical delivery, and append-only attempts. The durable record stores no raw reset URL and no raw token. Logs inherit the same rule.
| Record | Stable key | Safe evidence | Never treat as proof |
|---|---|---|---|
| Recovery intent | random operation ID | requested time, tenant, policy version | account ownership |
| Logical delivery | operation ID plus channel | template revision, recipient digest | inbox receipt |
| Attempt | attempt ID | start/end time, outcome class, status, retry decision | human readership |
| Token state | token digest or opaque reference | issued, expires, consumed/revoked | message delivery |
The state transition is pending -> sending -> accepted, or sending -> deferred -> sending. Terminal rejection becomes failed; exhausting the recovery policy becomes expired. Those names are deliberately about system knowledge. accepted means the transport accepted the request, not that the learner received or opened it.
Persist the attempt before network I/O, then finalize it after the response. If a worker dies in between, reconciliation sees a stale sending attempt and handles it as ambiguous. Do not blindly replay an ambiguous attempt. First query transport status when the integration exposes a trustworthy lookup keyed by your stable operation ID. When it does not, choose and document a policy: wait for a bounded interval before one replay, or route the case for operational review. The right choice depends on token lifetime and the harm of delay versus duplication.
Make the retry policy executable
The following Go code is the core policy I want even when the surrounding service runs on Node.js. The interface is intentionally generic, and the function has no sleeping or networking hidden inside it. A Node.js worker can implement the same state transition and schedule next_attempt_at in its queue.
package retry
import (
"errors"
"math/rand/v2"
"strconv"
"strings"
"time"
)
var ErrInvalidRetryAfter = errors.New("invalid Retry-After")
func RetryAt(now time.Time, value string, attempt int) (time.Time, error) {
value = strings.TrimSpace(value)
if value != "" {
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds >= 0 {
return now.Add(time.Duration(seconds) * time.Second), nil
}
if date, err := time.Parse(time.RFC1123, value); err == nil {
if date.Before(now) {
return now, nil
}
return date, nil
}
return time.Time{}, ErrInvalidRetryAfter
}
// Full jitter on a capped exponential window; persist the chosen timestamp.
if attempt < 0 {
attempt = 0
}
if attempt > 6 {
attempt = 6
}
ceiling := 2 * time.Second * time.Duration(1<<attempt)
return now.Add(rand.N(ceiling)), nil
}
A production parser should accept all HTTP-date formats required by RFC 9110 rather than relying on this compact example's single Go layout. Keep that parser covered by fixtures for integer delays, future dates, past dates, malformed values, and clock skew. On malformed input, fall back to the documented local policy and record why; do not spin immediately.
Jitter matters because a shared fixed delay makes every blocked worker wake together. Cap both the delay and the total attempt horizon. Persist the selected next-attempt timestamp so a process restart does not redraw a shorter delay. Also enforce concurrency and request-rate limits before the sender call; backoff alone reacts after overload has already happened.
The worker should claim due rows with a lease, write a unique attempt ID, and commit deferred plus next_attempt_at atomically after a 429. The web process returns after durable enqueue. Never hold an HTTP request open across minutes of backoff.
Compare integrations at the failure boundary
Integration effort is not the number of lines in a happy-path SDK example. For this job, compare candidates by asking whether the adapter can preserve the state machine without provider-specific logic leaking into account recovery.
The useful questions are concrete. Does the send interface accept a caller-generated idempotency key, and what scope and retention does that key have? Can the system return a stable message reference? Are 429 responses documented with Retry-After behavior? Is delivery status available through signed events or a query interface? Can event duplicates and out-of-order arrival be reconciled? How are suppression and permanent rejection represented?
No answer should be inferred from a generic "successful" response. Verify each contract in current documentation and a sandbox or controlled test account, then pin the observed behavior in adapter tests. If native idempotency is absent, the local outbox still prevents two workers from independently initiating the same logical send, but it cannot erase ambiguity after a connection loss. Say so in the runbook.
This design has real limitations. It adds a database write path, reconciliation work, and an on-call state machine, so it is a poor fit for a disposable newsletter where duplicates have low impact and no recovery secret exists. A direct send may be the better trade-off there. It is also insufficient for a legally defined delivery requirement: counsel and compliance owners must define what evidence counts, how long it is retained, and which events require escalation. Engineering cannot turn provider acceptance into legal proof by renaming a database column.
Operate the evidence trail, not just the queue
Deploy the adapter behind a feature flag and shadow only decision logic, never duplicate live sends. Before rollout, test 429 with delta-seconds, 429 with an HTTP date, malformed headers, a timeout after request write, worker death after remote acceptance, duplicate queue delivery, and delayed or duplicated status events. The acceptance criterion is the final state and evidence trail, not merely the number of worker executions.
Alert on age, not raw retry count. A small burst of retries may be harmless; the oldest pending recovery approaching its token expiry is user-visible risk. Useful signals include pending age percentiles, intents expiring before acceptance, ambiguous attempts, 429 rate by adapter, permanent rejection rate, and reconciliation lag. Keep identifiers joinable through the operation ID, but hash or otherwise minimize recipient data according to the organization's threat model and retention policy.
Three runbook branches are enough to keep the first response disciplined:
- If 429 rises while pending age remains inside the recovery objective, preserve backpressure and inspect quota or traffic changes.
- If pending age threatens token expiry, stop optional traffic sharing the same constrained path and follow the preapproved capacity or failover procedure.
- If ambiguous attempts rise, pause automatic replay for that class and reconcile before sending again.
DMARC belongs in the same operational picture, but at a different layer. RFC 7489 describes domain owners publishing policy and receivers reporting authentication results. It helps protect domain use and provides aggregate feedback; it does not provide per-recipient proof that a particular reset message was read. SPF, DKIM, DMARC, queue acceptance, and mailbox delivery are related signals with different meanings.
The durable decision rule is short: accept one recovery intent, schedule attempts from persisted state, trust explicit protocol signals, and label evidence according to what it proves. That produces an audit record support can explain and an on-call engineer can repair without issuing a fresh secret every time the network is uncertain.
Top comments (0)