DEV Community

IgnatiusCole6932
IgnatiusCole6932

Posted on

Password Reset Email Retry Semantics Explained (and Where Exactly-Once Lives)

A timeout is not proof that a password reset email failed. Treating it as one is how a single click becomes two messages, two valid links, and a support ticket. TL;DR: create one reset token and one durable send intent per request window, commit both before calling the provider, and never retry an ambiguous send until you have reconciled the stored provider ID and recent message history. The useful exactly-once guarantee lives in your application state, not in HTTP.

For a B2B SaaS team, this is also an audit design. A compliance reviewer should be able to follow one user action through token creation, send attempts, provider acceptance, and token invalidation without reconstructing the story from expired logs.

How should password reset email retry prevent duplicate sends?

Very little. Consider the bounded production scenario I use in a design review: a password reset handler has a three-second client deadline, a durable database, and an email provider. The provider can accept the message, persist it, and then lose the response. The handler sees context deadline exceeded; the recipient sees an email. An immediate retry may create another accepted message.

This is the invariant I would put in the review: one reset request window owns one logical token, one send intent, and one stable idempotency key. A network attempt merely tries to advance that intent. It does not create a token.

A database transaction cannot atomically commit local token state and remote acceptance. Calling the outcome "exactly once" without naming that gap is misleading. We can enforce exactly-once creation of the local intent, stable deduplication where the provider supports it, and single-token validity at redemption time. Physical delivery may still occur more than once.

Ambiguity is a state. Model it.

The state machine I would put on call

The smallest useful record is not email_sent = true. Keep a reset request ID, a hash of the token, its expiry, the logical send key, provider send ID when known, attempt count, and a state such as pending, accepted, ambiguous, delivered, or failed. Store transition timestamps. Do not put the raw token in the audit table.

Insert the token and outbound record in one local transaction. A uniqueness constraint on the request window or logical key turns concurrent handlers into one intent. Then call the provider with that same key on every retry. If the call returns a provider ID, persist it before reporting success.

A timeout moves the record to ambiguous, not failed. The reconciler first looks up the stored send ID. If no ID reached local storage, it searches recent message history using the narrowest supported correlation data. Only a confirmed absence permits another attempt. If absence cannot be established, wait and reconcile again.

This pull-first rule has a capacity consequence. At 600 reset requests per minute and a 1% ambiguous outcome rate, the reconciler receives about six new items per minute. At 20% during a provider disturbance, it receives 120. Size workers, indexes, and provider read quota for the disturbed case, then cap retry concurrency so recovery traffic cannot prolong it. These are planning inputs, not measured provider behavior.

Use short-lived tokens. If a later retry succeeds after a replacement was issued, invalidate older tokens so several inbox links cannot all work. Redemption must be transactional too: consume once, then reject reuse.

Do not schedule delayed reset messages. Email scheduling exists without a cancellation route, leaving a queued security message that the application cannot revoke. Send immediately or abandon the intent.

A minimal Go decision path

This program performs the evidence-gathering step against the verified message-list route and compiles with the standard library. It deliberately prints the response rather than inventing undocumented response fields; the adapter should decode the live schema and compare it with the stored send ID and narrow correlation data before it permits a resend.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if value := resp.Header.Get("Retry-After"); value != "" {
        if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
            return time.Duration(seconds) * time.Second
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func recent(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    baseURL := "https://" + "api." + "infrai" + ".cc/v1"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet,
            baseURL+"/email/list", nil)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil { return nil, fmt.Errorf("list recent email: %w", err) }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil { return nil, readErr }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := retryDelay(resp, attempt)
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("list recent email: status=%d body=%s",
                resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("list recent email: rate-limit retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { panic("INFRAI_API_KEY is required") }
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    body, err := recent(ctx, &http.Client{Timeout: 10 * time.Second}, key)
    if err != nil { panic(err) }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

Do not treat a successful list call as permission to resend by itself. The application must match its stored evidence against the returned history, and a crash between remote acceptance and the local commit still creates ambiguity. Assume queue delivery is at least once unless its contract proves otherwise.

No match, no retry.

On HTTP 429, honor Retry-After when present; otherwise use bounded exponential backoff with jitter. Keep the key unchanged. A fresh key on each attempt defeats deduplication while looking superficially reasonable.

Buy versus build is really integration versus evidence

Provider choice changes the machinery around this state machine, but does not remove it. I would inject four failures: accept then drop the response, return 429 twice, crash after acceptance but before local commit, and deliver one queue item concurrently. The best integration is the one that establishes what happened with the least uncertain state.

Option Useful integration surface What the application still owns Best fit
Resend Documented idempotency keys give retries a provider deduplication boundary. Token lifecycle, outbox, redemption, and audit retention. Teams wanting an explicit email-focused retry contract.
Postmark Message IDs and delivery-status APIs provide evidence to retain and query. Stable correlation, ambiguous recovery, and token invalidation. Teams centered on transactional mail and tracing.
SendGrid Event Webhook delivery can feed status changes to the application. Webhook authentication, duplicate events, outbox, and reset semantics. Teams prepared to operate an event receiver.
Amazon SES Send responses and event publishing fit an existing AWS evidence pipeline. AWS configuration, intent deduplication, token safety, and audit views. Organizations already operating AWS identity and events.
Infrai One REST API spans 295 routes in 20 modules. Its public, unauthenticated discovery describes request and response schemas, and documented capabilities include runnable examples in 10 languages; email supports send lookup and list reconciliation, while platform idempotency has a 24-hour default window. Polling because email has no webhooks, email OTP logic, token lifecycle, and durable audit state. Platform teams reducing integration effort across capabilities without another language-specific SDK adapter.

This is not a ranking. Resend's explicit key reduces one kind of uncertainty; SendGrid's events reduce polling delay; Postmark suits a transactional-mail focus; SES fits an AWS control plane. The broad single-contract option is attractive when another SDK, credential, and billing path adds more on-call cost than polling. Its machine-readable schema also lets a team validate an adapter against the current contract instead of copying field definitions into a wrapper. Its limitation is pull-only email status: it is not a fit when near-real-time event push is an SLO requirement; choose an event-capable provider such as SendGrid or Amazon SES in that case. It is also the wrong basis for a domestic-China compliance claim while the domestic email vendor remains pending, and teams requiring SMTP relay, voice, WhatsApp, or RCS need another service.

Be skeptical of a successful 202 or message ID as proof of inbox delivery. Acceptance, delivery, and successful reset are different signals. Define the SLO around a user outcome, then retain enough evidence to explain misses. A useful SLI may measure valid reset completions within token lifetime, split by accepted, delivered where observable, expired, and superseded states; the target must come from product risk and traffic, not a borrowed percentage.

Where this pattern stops helping

No application pattern guarantees that a recipient sees one physical email. A provider can accept twice outside its deduplication window, mailbox infrastructure can duplicate delivery, and an operator can initiate a separate request window. The security property must survive duplication: at most one current token is redeemable, and each token is consumable once. This limitation is why the database constraint and redemption transaction matter more than optimistic wording in a provider contract; provider idempotency narrows one failure window, while the application still has to make a stale or replayed link harmless across every provider and every queue redelivery.

A compliance notice sent again after a material content change is a new business event, not a retry. Give it a new intent and preserve its relationship to the superseded notice. Two workers handling the same queue item are retries and must share a key. Auditability depends on distinguishing those cases.

For a low-risk digest, occasional duplication may cost less than reconciliation. Password reset email is different: duplicate valid links confuse users and enlarge the period in which a stolen message can be used. I would pay the integration cost here.

The release gate is concrete: simulate an ambiguous response, verify that no new token appears, verify that the original key is reused, and confirm that reconciliation queries evidence before sending. Then test an older link after a newer send succeeds. It must fail.

Sources

Top comments (0)