Dev.to · 6 min read

Preventing Duplicate Password-Reset Notifications (Under SMS Timeout and Retry Pressure)

Preventing Duplicate Password-Reset Notifications (Under SMS Timeout and Retry Pressure)

Treat an SMS timeout as an unknown outcome, not a failed send: accept each password-reset event once, persist its expiry and idempotency key before dispatch, and retry only through a worker that can reconcile the original attempt. For a short-lived e-commerce reset token, compliance evidence is the deciding constraint. The system must be able to show what it accepted, what it attempted, when it stopped, and why, without storing the token or message body in an audit log. This changes the shape of the endpoint. A Node.js Express handler may receive the event, but it shouldn't hold the HTTP request open while an SMS provider decides the final delivery state. Return an accepted response after durable admission, then expose status from local state. The Go example below shows the same transport-independent contract because the hard part isn't an Express API call; it's controlling ownership of retries. One event, one logical notification. How should event notifications handle SMS timeout, retry, and duplicate sends? Use two identifiers with different jobs. event_id identifies the business action, such as one password-reset request. idempotency_key identifies the logical notification command. A unique constraint on the key makes two concurrent HTTP requests converge on one stored record; checking memory before an insert is not enough because two processes can pass that check together. A timeout leaves three possible realities: the provider never accepted the request, it accepted the request but the response was lost, or it accepted and sent the message before the caller stopped waiting. Retrying immediately as though the first case were certain is how customers receive two reset messages. Declaring success is no better. The durable record should therefore enter dispatch_unknown, keep the provider's attempt identifier when one exists, and move through reconciliation before another send can be authorized. Status polling serves a different purpose from retry. Polling reads the provider's view and updates the local record; it must not create a second message. That separation is small on a diagram and easy to blur in code — especially when a generic checkAndRetry() function owns both operations. Don't combine them. The expiry is also a dispatch boundary, not presentation metadata. Before every attempt, compare the current time with expires_at. Once the reset token is too close to expiry for a useful delivery, mark the notification expired and stop. The exact safety margin depends on observed queue and carrier delay, so I'm not sure a universal number would be defensible; resolve it from your own latency distribution and product policy, then record the chosen margin as configuration that can be audited. The delivery contract and failure states The useful contract is narrower than “send this string.” It says: admit a password-reset notification exactly once for a stable event identifier, never dispatch it after expiry, retain evidence of each state transition, and let callers inspect the logical result without triggering work. “Exactly once” here describes admission of the logical command. It does not pretend that an external SMS network participates in the same database transaction. Local state Meaning Permitted next action accepted The event and expiry are durably stored One worker may claim it dispatching A lease-holder owns the current attempt Wait or reclaim an expired lease dispatch_unknown The request outcome is ambiguous Reconcile status; do not blindly resend sent The provider accepted the logical message Poll status or finish delivered Delivery evidence was observed Finish failed_final A classified permanent failure occurred Finish and surface a safe user action expired The reset window closed Finish; require a new reset request Every transition needs a timestamp, old and new state, event ID, attempt number, and a reason code. Keep credentials, the reset URL, the token, and the full phone number out of those records. A redacted destination fingerprint can support correlation, but access to it still belongs under the same retention and authorization controls as the rest of the evidence. There is a buy-versus-build decision hiding here. A managed notification service can own provider reconciliation and channel routing, reducing on-call surface, but its status vocabulary and evidence export may not match the controls an auditor expects. A direct provider integration exposes more detail and leaves fewer translation layers, while your team owns leases, retry classification, retention, and every 02:00 alert. A self-hosted dispatcher gives the strongest control over data placement and change timing; the catch is that it is not suitable when the team cannot staff the queue, database, and delivery integration as an on-call product. Approach Compliance evidence On-call load Lock-in boundary Managed notification layer Verify export granularity and retention Lower application burden, more dependency monitoring Workflow and status model Direct SMS integration Build a record around raw attempt identifiers Retry and reconciliation stay with the team Provider request and status schema Self-hosted dispatcher Full control, full evidence design responsibility Highest operational ownership Internal schema and infrastructure No row wins by default. Stick with a managed layer when its evidence can satisfy the control and the reduced operational load matters more than adapting to its state model; choose direct integration when provider-level evidence is mandatory and the team can own the machinery; self-host only when control is worth the capacity and on-call budget. A safe Go implementation for idempotency and status polling The admission path should validate a compact schema, perform one transactional insert, and enqueue by record ID. If an AI agent or another dynamic client can originate events, publish the tool schema and reject unknown or missing fields at the boundary; explicit tool definitions reduce ambiguity about what the caller may send. The reset token itself should already have been created and stored by the identity system. This service needs a reference, an expiry, and a destination handle, not authority to mint credentials. Here is the core shape. Store.Admit must use a database unique constraint on IdempotencyKey; Sender and Store are interfaces so the HTTP layer cannot quietly acquire retry behavior. The same handlers can sit behind an Express-facing gateway without changing the state rules. package notifications import ( "context" "encoding/json" "errors" "net/http" "time" ) var ErrConflict = errors.New("idempotency key belongs to another event") type ResetEvent struct { EventID string `json:"event_id"` IdempotencyKey string `json:"idempotency_key"` DestinationRef string `json:"destination_ref"` ResetRef string `json:"reset_ref"` ExpiresAt time.Time `json:"expires_at"` } type Record struct { ID string `json:"id"` EventID string `json:"event_id"` State string `json:"state"` ExpiresAt time.Time `json:"expires_at"` UpdatedAt time.Time `json:"updated_at"` } type Store interface { Admit(context.Context, ResetEvent) (Record, bool, error) Get(context.Context, string) (Record, error) } type Queue interface { Publish(context.Context, string) error } type API struct { Store Store Queue Queue Now func() time.Time } func (a API) AdmitReset(w http.ResponseWriter, r *http.Request) { var event ResetEvent dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News