Record each customer-support password-reset request before dispatch, and treat a timed-out email call as an unknown outcome rather than permission to send again. Short answer: reuse one reset challenge for a bounded request window, reconcile the original attempt first, and permit a new delivery attempt only when its effect can be established or the short-lived challenge has expired. A transport acknowledgment is not evidence that a mailbox received one message, so an absolute exactly-once delivery promise is not defensible across an opaque email boundary.
How can a password reset email retry avoid duplicate sends after timeout?
The first invariant is about the credential, not the envelope: repeated clicks on the same support reset action must not mint competing valid links. The second is about control: a retry must not start a fresh send while a prior attempt may still be in flight. The third is about privacy: diagnostic records must not contain the reset token or the full link. OWASP recommends single-use reset tokens that expire and advises against leaking whether an account exists. Those properties belong in the application even if delivery succeeds every time. Consider a support agent who clicks once, sees the request hang, and clicks again: the page cannot tell whether the first delivery request crossed the network boundary. The retry key should identify those two clicks as the same action only if the interface deliberately treats them as a retry; a later customer-initiated reset must get a distinct action ID. A database uniqueness constraint can enforce that local identity, but cannot report what happened in the remote mailbox. This distinction determines what the operator can safely claim during a live support conversation.
Unknown means unknown.
There are two distinct boundaries. A database transaction can atomically create a challenge and an outbox record. It cannot atomically commit an external email delivery. An HTTP timeout can occur after the delivery system accepted the request but before the caller received its response. Retrying immediately can produce two emails with the same link; minting a new challenge on retry can also make the first email confusing or unusable, depending on token invalidation policy. Neither outcome is fixed by adding more transport retries.
For a support workflow, decide what "same request" means before choosing a key. A random action identifier generated when the support agent submits the reset action can coalesce transport retries of that action. A later, intentional customer request should remain possible. Do not use an email address alone as a permanent idempotency key: it would suppress legitimate future resets, and retaining it as a telemetry label creates unbounded cardinality and unnecessary personal data exposure.
The decision record
The primary decision axis is reliable delivery within the challenge's expiry, not a promise of mathematically exact message count. The system keeps a durable attempt state and a bounded reconciliation period. An unknown outcome stays unknown until a trustworthy lookup, callback, or expiry resolves what action is permissible. A delivery-system idempotency facility helps only if its documented scope and retention cover the ambiguity window; it cannot prove mailbox delivery.
| Approach | Ambiguous response | Duplicate exposure | Valid use |
|---|---|---|---|
| Blind transport retry | Starts another send | High when the first request was accepted | Only when duplicate messages are explicitly acceptable |
| Durable outbox plus reconciliation | Holds the action while checking the first attempt | Reduced, but unresolved external effects remain possible | Short-lived reset links with a measurable recovery window |
| Suppress every retry | Stops after the first call | Low from the application, but lost messages stay lost | Workflows where failure to deliver is less harmful than duplication |
The middle choice requires a deadline. For illustration, with a 15-minute challenge lifetime, a 2-minute reconciliation window leaves 13 minutes for escalation or a fresh user-initiated request. These are example policy inputs, not universal service guarantees. A team should derive both from its own delivery latency and security requirements; if reconciliation cannot finish before expiry, a second blind send of the old link is a poor recovery policy.
Time is finite.
How does the critical path behave?
The request handler atomically stores a challenge and one dispatch intent keyed by the action identifier. A worker claims the intent with a lease, sends the same message identifier on every allowed retry, and persists any authoritative acceptance response. On timeout, it records unknown, not failed. The following diagnostic call is illustrative: the endpoint is an internal control-plane interface, not a claim about a provider API. Its response should report an outcome only when the underlying delivery system actually supports correlation by message identifier.
curl --fail-with-body --request GET \
--url 'https://delivery.internal.example/attempts/reset-action-7f3a' \
--header 'Accept: application/json'
If no authoritative lookup exists, do not relabel unknown as not_sent merely because no callback arrived. Callbacks can be delayed or lost. A support agent should see a neutral status and a deliberate option to initiate a new reset after the policy window; any old challenge should be invalidated according to the token policy. The UI must not reveal account existence to an unauthenticated requester.
Test the boundary by forcing the client connection to time out after the downstream system accepts the request. Then test a timeout before acceptance, a repeated support click, a late acceptance callback, and a worker lease expiring mid-send. Check both the number of outbound attempts and which links remain valid. A successful happy-path test misses the only state that matters here.
What should the audit trail retain?
Count bytes before adding fields. At 100,000 reset actions per day and 1 KB of retained diagnostic data per action, raw records alone amount to about 100 MB per day, or about 3 GB over 30 days, before indexes and replicas. These are arithmetic illustrations, not measured workload figures. Keeping a unique action ID as a metrics label would also create roughly 100,000 new label values per day at that illustrative rate. Use bounded labels such as outcome class and delivery channel for aggregate metrics; keep action-level correlation in access-controlled records with a defined retention period.
Record the action ID, challenge ID, attempt ID, state transition timestamps, and coarse error category. Do not log link URLs, token material, full email addresses, or arbitrary downstream payloads. Store the minimum information needed to reconcile an unknown attempt and investigate a repeated send, then expire it under the organization's retention policy. Measure unknown age against challenge time remaining, rather than treating a growing retry count as progress.
This also changes deployment practice. A worker restart must preserve leases and attempt identity; a schema migration must preserve the uniqueness constraint on action ID. Alert on unknown attempts nearing expiry and on duplicate acceptance observations, with a low-cardinality counter plus restricted trace lookup. An on-call engineer needs to know which boundary failed without turning every reset link into durable log data.
When is the rejected option appropriate?
The limitation of reconciliation is its dependence on a trustworthy remote status signal. Without one, the ledger can preserve uncertainty but cannot eliminate it; waiting also consumes part of a short token lifetime. That trade-off makes the approach unsuitable when the delivery system has no correlation facility and the business requires an immediate, independently verified send outcome. In that setting, use an explicit human recovery path or accept duplicate delivery as a documented product policy. An immediate second send is reasonable when the content is harmless to receive twice and a missed delivery is materially worse. A short-expiry password reset is different: two messages can mislead the customer and complicate a support conversation. Suppressing all retries is defensible when a manual recovery route is reliable, but it shifts failures to people and should be an explicit product decision. Neither policy should be chosen by an HTTP client's default retry setting.
The practical outcome is bounded uncertainty, not exactly-once email delivery. Keep one challenge per action, surface unknown sends honestly, and spend the observability budget on state transitions that distinguish a safe recovery from a duplicate.
References
- OWASP Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- RFC 9110, HTTP Semantics, idempotent methods: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2
- RFC 5321, Simple Mail Transfer Protocol: https://www.rfc-editor.org/rfc/rfc5321.html
- OpenTelemetry, attribute and metric cardinality guidance: https://opentelemetry.io/docs/specs/semconv/general/attribute-naming/
Top comments (0)