DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

Reliable Password Reset Email Retries After 429 Responses (Without Duplicate Storms)

Short answer: treat an email API 429 as backpressure, keep one durable delivery job per password-reset request, honor a valid Retry-After, and make the user cooldown independent from the worker retry schedule. The browser should receive the same neutral response whether an account exists, while a queue owns delivery attempts after the request ends.

The tempting implementation sends directly from the reset endpoint and lets the user request another message when it fails. That couples three clocks that have different jobs: the security lifetime of the reset token, the user-facing cooldown, and the provider-facing retry delay. Under throttling, those clocks turn one click into duplicate messages, old links, or a button that claims success while no durable work exists. Delivery reliability starts by separating them.

This pattern also fits an edtech receipt sent after payment settles. The payload and security stakes differ, but the operational rule is identical: commit the business event, create one durable notification job, then let delivery absorb provider backpressure without replaying the payment or inventing another receipt.

How should a password reset email API handle 429 rate limits?

Return from the public endpoint after recording intent, not after waiting for the email provider. For a reset request, create a random reset token, store only the verifier or digest needed to validate it, and enqueue a message that points to the current reset record. Do all of that in one database transaction where possible. If queue publication is separate, use an outbox row committed beside the reset record so a crashed process can't leave valid state with no delivery job.

Keep the public reply neutral. It shouldn't confirm that an address is registered, and a provider's 429 should never leak into the browser as a special account-dependent response. A typical response means only that the request was accepted for processing. The worker, not the user, handles transport pressure.

There are four distinct identifiers worth keeping:

  • A request ID follows one browser action through logs.
  • A reset ID names the current security operation for the account.
  • A delivery ID names one logical email and is the idempotency boundary.
  • A provider message ID, when available, correlates accepted mail with later delivery events.

Don't use the token string as an idempotency key or a log field. It is a credential. Use the delivery ID, and redact the reset URL from structured logs.

When the provider answers 429, parse Retry-After if it is valid and schedule the existing delivery job for that time. If it is absent or unusable, apply capped exponential backoff with jitter. Do not sleep inside a web process, hold a database transaction open, or create a fresh reset token merely because transport is throttled. A short-lived worker lease can expire; the durable next_attempt_at value cannot.

The repeat-request endpoint follows a different rule. During the cooldown it records no new delivery and returns the same neutral response. After the cooldown, it may rotate the reset operation and enqueue exactly one replacement message. Decide explicitly whether an older link remains valid. For password recovery, invalidating the previous reset when a replacement is issued gives the cleanest security rule, but the email copy must warn that only the newest link works.

That's the key split.

Model backpressure as durable state

A retry loop is easy to write and surprisingly easy to get wrong. The useful unit is not "call the API again"; it is a state transition guarded by a lease. One worker claims a due delivery, attempts it, then records accepted, retry_wait, or a terminal result. Another worker can reclaim an expired lease without creating another logical message.

The following Python sketch leaves transport and storage behind interfaces on purpose. Its important behavior is that a 429 reschedules the same delivery ID, caps delay, adds jitter, and consumes a retry budget. The public request handler never runs this loop.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import random


@dataclass(frozen=True)
class Delivery:
    delivery_id: str
    attempts: int
    expires_at: datetime


def retry_delay(attempts: int, retry_after_seconds: int | None) -> timedelta:
    if retry_after_seconds is not None and retry_after_seconds >= 0:
        seconds = min(retry_after_seconds, 15 * 60)
    else:
        base = min(2 ** attempts, 15 * 60)
        seconds = random.uniform(base * 0.5, base)
    return timedelta(seconds=seconds)


def deliver_once(delivery: Delivery, mailer, store) -> None:
    now = datetime.now(timezone.utc)
    if now >= delivery.expires_at:
        store.mark_expired(delivery.delivery_id)
        return

    result = mailer.send(delivery.delivery_id)
    if result.accepted:
        store.mark_accepted(delivery.delivery_id, result.message_id)
        return

    if result.status_code == 429:
        delay = retry_delay(delivery.attempts, result.retry_after_seconds)
        next_attempt = min(now + delay, delivery.expires_at)
        store.reschedule(delivery.delivery_id, next_attempt)
        return

    store.apply_failure_policy(delivery.delivery_id, result.category)
Enter fullscreen mode Exit fullscreen mode

Production code needs a stricter parser around Retry-After, an atomic claim operation, and a maximum-attempt policy. It also needs to distinguish a retryable transport response from a permanent recipient or policy rejection. I'm not sure one universal retry budget exists; the right number depends on token lifetime, provider contract, queue latency, and the recovery path available to the learner. Those inputs should be configuration reviewed with security and support, not magic constants copied from an SDK sample.

Avoid a subtle expiry mistake in the sketch: if next_attempt equals token expiry, there may be no useful time left to deliver and click. Set a final-attempt cutoff earlier than expiry, with room for normal inbox delay. Once that cutoff passes, mark the delivery expired and let a new user action create a new reset operation. Endless retrying is not reliability.

For an order receipt, the terminal policy changes. A receipt doesn't become a security liability when its link ages, so the job can remain recoverable longer; the payment event still must never be replayed. This is why notification state belongs beside, but not inside, the business transaction.

Make duplicates harmless before tuning retries

An idempotency key helps only if every layer agrees on its scope. The database should reject a second active delivery for the same reset generation. The queue should tolerate at-least-once execution. The mail adapter should pass a stable logical identifier if its contract supports one. If it doesn't, the worker still needs an atomic state transition before and after the call, plus reconciliation for the narrow uncertainty window where the remote side accepts a message and the worker loses its acknowledgement.

That uncertainty cannot be erased by prettier backoff math.

Design the email so duplicates are survivable. Both copies should point to the same current reset operation until a deliberate replacement request rotates it; redemption must be single-use; and a successful password change must invalidate the operation. Never put the recipient address, token, or complete reset URL into metrics labels. High-cardinality secrets are still secrets.

The repeat-request cooldown protects both people and infrastructure, but it is not the same as a per-IP abuse limit. Apply layered controls to the account key, a privacy-preserving network signal, and broader system capacity. Keep responses uniform enough that timing and wording don't become an account-discovery side channel. Also provide a support route for learners who have lost access to the mailbox. A cooldown with no recovery path becomes a lockout mechanism.

Password reset mail is transactional, so don't casually attach marketing content or mailing-list controls to it. RFC 8058 defines one-click unsubscribe behavior for list mail; it is useful at the boundary where a message really is subscription traffic, not as decoration on a security message. Keep those streams and their consent records separate.

SMS fallback deserves the same boundary discipline. The WebOTP API can help a browser receive an SMS-formatted one-time code after user consent, but it doesn't turn SMS into email delivery confirmation or justify silently changing recovery channels. Channel enrollment, disclosure, rate limits, and account-recovery policy still apply.

Observe the state machine, not just API latency

An email API returning quickly tells you very little about whether the learner can reset a password. Measure counts and age by state: queued, leased, accepted by transport, delivered when trustworthy event data exists, retrying after 429, permanently rejected, expired, and redeemed. The most useful alarm is often the age of the oldest eligible delivery, because a calm request rate can hide a stuck queue.

Log transitions with request ID, delivery ID, attempt number, normalized outcome category, scheduled retry time, and provider message ID. Leave the address and token out. Track the ratio of reset requests to accepted deliveries and successful redemptions, but interpret it carefully: users can request a reset and then remember their password. Your mileage may vary across school calendars, shared family inboxes, and institutional mail filters.

Test ugly sequences before deployment — two repeat clicks at the cooldown boundary, two workers claiming the same job, a 429 with no usable delay, process loss after remote acceptance, token redemption during a retry wait, and a replacement request while an older email is in flight. Use a fake clock and scripted mail adapter so every transition is deterministic. Then run a small canary with dashboards already open.

No heroics.

Roll out with explicit limits

Start by putting durable jobs behind the existing request endpoint without changing the email template. Next, introduce stable delivery IDs and state metrics. Only then enable scheduled 429 retries and the separate user cooldown. This sequence makes each behavioral change observable and gives rollback a narrow surface.

The catch is operational weight. A database-backed outbox, worker leases, reconciliation, and delivery-event ingestion are not suitable when the message is genuinely best-effort and carries no security or financial consequence. For a tiny internal tool, stick with a simpler queued sender and accept manual recovery. For password resets and settled-payment receipts, the durable state machine earns its keep because losing intent or multiplying messages creates support and trust problems that a faster API call cannot repair.

Choose limits from the actual token lifetime, provider guidance, and support promise. Document who owns exhausted jobs, how a learner recovers, and what constitutes delivery success. A reliable reset flow doesn't promise that every mailbox will accept every message. It promises that backpressure is controlled, duplicates are bounded, secrets stay out of telemetry, and failure ends in a visible state rather than a vanished request.

References

Top comments (0)