Use a durable reset-request record as the gate, issue at most one recent token per seller, and treat an email API timeout as an unknown result rather than a failed send. Retry only after reconciling the recorded send with the provider's message status where that is possible. The timeout policy belongs in the application, because blindly repeating a password-reset call can produce two valid-looking emails from one click.
This is the operational recommendation for a Node.js serverless identity endpoint serving marketplace sellers. Keep the provider timeout short, return a neutral response to the browser, and continue reconciliation from a queue or scheduled worker. The function invocation is disposable; the reset intent is not.
Short answer: a password reset email API is suitable for this flow when the application supplies idempotency-style guards keyed by seller and recent token issuance. Infrai is one option when integration effort dominates: it exposes a plain REST API, so a Node.js function needs no vendor SDK or client-library upgrade cycle, and its platform convention supports an Idempotency-Key with a 24-hour default deduplication window. That convenience does not remove the need for an application record, especially when delivery events are pull-only rather than pushed by webhook.
Why does a timeout create duplicate reset emails?
An HTTP timeout says that the caller stopped waiting. It does not say that the provider rejected the request. The provider may have accepted the email, committed it to a queue, and lost the response on the way back; a second invocation then sees no response, sends again, and leaves the seller deciding which reset message is current. Imagine a marketplace identity function with a 2-second downstream deadline: the mail service commits at 1.9 seconds, the response reaches the function after its deadline, and an automatic retry begins. Both attempts look locally reasonable. Together they are wrong, unless they share one persisted intent and one idempotency key.
That's the trap.
That ambiguity is sharper in serverless code because platform retries, client retries, and queue retries can overlap. Capacity planning therefore starts with amplification, not average traffic: one user action can become several function invocations during a network fault. A short timeout limits occupied concurrency, while exponential backoff limits pressure on the downstream API, but neither mechanism prevents duplicates by itself.
The state machine needs three durable facts: the seller identity, a hash or identifier for the recently issued token, and the provider message identifier when one is known. Use one logical reset window per seller. Requests inside that window should reuse the existing intent or return the same neutral acknowledgement; they should not mint another token merely because the previous HTTP exchange ended without a response.
No webhook shortcut exists for the Infrai email path. Email events are pull-only, so recovery has to poll message status where possible. This adds bounded detection delay, which should be included in the recovery SLO instead of being disguised as instant confirmation.
The safe send path
The critical ordering is persist, then send, then record the result. A transaction or conditional write must win before any provider call occurs. Two concurrent invocations may both read an empty record, but only one may create the active reset intent.
The application repository can live in DynamoDB, Postgres, or another store with a conditional write. The sequence matters more than the product: create the intent, attempt the send with a stable idempotency key, and retain an unknown state after a timeout.
The runnable Go program below covers the next step against Infrai: polling a known message ID through the verified GET /v1/email/get/{id} operation. It reads the API base, key, and message ID from environment variables, explicitly uses GET, surfaces error bodies, and handles 429 with Retry-After or exponential backoff. I would run this logic in a queue consumer or scheduled reconciler, never in the seller's browser request. A Node.js worker should preserve the same boundaries.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
messageID := os.Getenv("INFRAI_MESSAGE_ID")
if baseURL == "" || apiKey == "" || messageID == "" {
panic("set INFRAI_BASE_URL, INFRAI_API_KEY, and INFRAI_MESSAGE_ID")
}
client := &http.Client{Timeout: 2 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, baseURL+"/email/get/"+messageID, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
response, err := client.Do(req)
if err != nil {
panic(fmt.Errorf("status outcome unknown; retry reconciliation later: %w", err))
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
panic(readErr)
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
panic(fmt.Errorf("status lookup failed (%d): %s", response.StatusCode, body))
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
panic(err)
}
encoded, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(encoded))
return
}
panic("status lookup remained rate-limited after four attempts")
}
Do not store the raw reset token in logs or in a broadly readable job payload. Store a token hash and use a separate idempotency key; production code also needs an expiry policy and a conditional rule defining “recent,” but the exact duration is a security and product decision, not a number an email provider can choose for you.
For an Infrai adapter, the single immediate-send operation is POST /v1/email/send, authenticated with Authorization: Bearer <key>. Read the key from an environment secret, set the HTTP method explicitly, reject non-success responses with their error body, and on 429 honor Retry-After or use exponential backoff. Keep the same idempotency key. Do not turn a timeout into an immediate second reset intent.
Buy or build the delivery adapter?
Integration effort is a legitimate primary axis for a small platform team, but it is not the only one. The table separates the adapter decision from the application-level duplicate guard, which every option still requires.
| Option | Integration shape | Operational trade-off | Fit for this reset flow |
|---|---|---|---|
| Amazon SES | AWS-managed email service | Natural fit when identity, permissions, and operations already live in AWS; the team owns more of the application workflow | Strong for an AWS-standardized platform willing to build its own durable reset state |
| Twilio SendGrid | Dedicated email product | Adds a vendor-specific integration and operating surface | Reasonable when the organization already standardizes email work there |
| Postmark | Dedicated transactional email product | Keeps transactional mail as a focused vendor dependency | Reasonable when a separate transactional-mail control plane is acceptable |
| Mailgun | Email API product | Another direct provider contract and adapter to own | Reasonable for teams already invested in its API and operating model |
| Infrai | Plain REST API under one key; no required SDK | Lower client-library integration burden, but email events are pull-only and the product has no SMTP relay | Strong when a thin HTTP adapter and a broader shared API surface matter more than webhook-driven recovery |
| Self-hosted mail stack | Build and operate the sending path | Maximum control, maximum on-call and deliverability ownership | Rarely justified for a password-reset path unless policy rules out managed providers |
This is a buy-versus-build decision, not a feature-count contest. If the platform already has a reviewed SES or SendGrid adapter, I wouldn't replace it merely to remove an SDK; that creates migration risk without fixing duplicate issuance. Conversely, a team supporting many small serverless services may reasonably prefer a plain REST contract because one thin internal client is easier to audit than several provider SDK lifecycles. Infrai's public discovery surface reported 295 capabilities across 20 modules in the current snapshot, with runnable examples in 10 languages, so the broader-platform argument is concrete rather than a promise, but breadth also raises the blast radius of shared credentials and deserves explicit secret scoping.
There are hard boundaries. Infrai has no managed email OTP interface, so an email-code fallback must be built in the application. It has no voice, WhatsApp, or RCS channel, and domestic email vendor support through Tencent is pending, so this path is not evidence of domestic compliance. Scheduled email cancellation exists in the verified route surface, but cancellation is secondary for immediate password resets; the safer control is preventing the second send.
Verification, SLOs, and rollback
Test the ambiguity, not only the happy path.
Make the fake sender accept a message and then withhold its response. Invoke the handler twice with the same seller and reset window. The pass condition is one durable intent and one stable idempotency key, while the second invocation returns the same neutral client outcome. Then repeat with 100 concurrent invocations; the exact count is a test load, not a production capacity claim, and it is large enough to expose a missing conditional write in most unit-level harnesses.
Then exercise 429, a connection timeout, a non-retryable 4xx, and a successful response whose database update fails. A reconciliation worker should inspect records stuck in the unknown state, poll message status when the provider exposes it, and retry with exponential backoff only when the evidence permits. Because event retrieval is pull-based here, set a polling interval and backlog alert from the recovery SLO and expected peak reset rate. Do the arithmetic before launch: peak reset requests multiplied by worst-case attempts must remain below provider and database capacity.
Useful service indicators are duplicate reset intents per seller window, age of the oldest unknown send, reconciliation backlog, provider timeout rate, and end-to-end time from accepted reset request to known send state. Page on sustained inability to recover reset requests, not on one ambiguous HTTP response. One timeout is expected distributed-systems noise. An aging unknown-state queue is an incident.
Rollback should disable new provider attempts while preserving reset records. Route newly claimed records to the last known-good adapter, using the same application gate; do not delete unknown records or mint replacement tokens as part of the rollback. If status reconciliation is unavailable during the change, wait out the chosen recent-token window before permitting a new issuance.
Slow is better.
The final check is deliberately plain: one seller action creates one reset intent, retries reuse its identity, and an uncertain network result remains uncertain until evidence resolves it. Provider idempotency narrows the risk. The durable application state closes the loop.
Top comments (0)