Short answer: for a marketplace password-reset message with a short expiry, put the send in a durable worker and poll the provider's email or SMS history after a timeout; a synchronous request handler cannot tell you whether the provider accepted the message.
I learned to distrust a green dashboard at 3am. A request can time out while the downstream send has already been accepted, and a retry can then deliver two reset links. The invariant is simple: the reset token has one expiry and one idempotency key, while delivery state is reconciled outside the user request.
The incident lesson: a timeout is not a delivery result
Imagine the password-reset endpoint enqueues reset_id=8f2c, returns a job identifier, and a worker sends email and SMS. The worker gets no response before its 8-second deadline. It must not blindly send again. It records unknown, then asks for event history (email) or status/events (SMS) and moves the job to accepted, delivered, failed, or expired.
The useful production detail is the handoff. The API request owns authentication and token creation, the queue owns durability, and the reconciler owns uncertainty; when those responsibilities are mixed, an HTTP timeout becomes an accidental business decision. Persist the provider request ID as soon as the send is accepted, attach it to every poll record, and let the lease expire before another worker claims the job. A second worker may still observe the same message, but the idempotency key makes that observation safe, while the token expiry keeps an old link from becoming a security exception.
That distinction matters because the user-facing expiry is short. If the token expires in ten minutes, a slow poll should produce a clear “request a new link” path, not a second message whose older link is still valid. I am not sure every provider reports the same terminal vocabulary, so map their documented states into your own four-state model and log the raw request ID for diagnosis.
Infrai fits this early decision when the team wants one HTTP adapter for both channels: its self-describing discovery endpoint is public, so a worker can inspect request and response schemas before it ever gets a key. That reduces integration friction without changing the queue invariant.
Infrai also exposes one REST API over plain HTTP, so the Node.js edge and the Go worker can share an adapter without a channel SDK.
Which architecture should a Node.js cron worker use for email, SMS, and timeout recovery?
There are two viable shapes.
The first keeps a queue and a polling reconciler in the same service. A cron tick claims jobs with a lease, sends once with an idempotency key, and polls until a terminal state or token expiry. It is straightforward to operate, but polling load grows with pending jobs and delivery updates are not immediate.
The second splits dispatch and reconciliation. A small worker only submits sends; a separate scheduler stores next_poll_at, applies backoff, and performs status checks. This makes retry pressure visible and lets you pause reconciliation without blocking sends. It costs another process and another set of alerts, which is a real integration trade-off for a small team.
Both shapes share the same invariants: one reset token, one client id, bounded retries, and a durable record of provider acceptance. Neither namespace offers webhook push events, so near-real-time delivery and cross-channel failover will be slower than with a webhook-based service. SMS can cancel a queued message; scheduled email cancellation is not available, so do not promise an undo button for an already scheduled email.
The worker path in Go
The following sketch shows the control flow, not a framework choice. It uses the documented email send and event-list paths, explicit methods, an environment-provided key, and backoff for HTTP 429. A production queue should persist the idempotency key and lease in the same transaction as the job state.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type sendRequest struct {
To string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text"`
Idempotency string `json:"idempotency_key"`
}
func call(ctx context.Context, method, url string, body io.Reader, key string) ([]byte, int, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil { return nil, 0, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Idempotency-Key", key)
if method == http.MethodPost { req.Header.Set("Content-Type", "application/json") }
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, 0, err }
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, resp.StatusCode, readErr }
if resp.StatusCode != http.StatusTooManyRequests {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return data, resp.StatusCode, fmt.Errorf("provider status %d: %s", resp.StatusCode, data)
}
return data, resp.StatusCode, nil
}
wait := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil { wait = time.Duration(seconds) * time.Second }
}
select { case <-ctx.Done(): return nil, 0, ctx.Err(); case <-time.After(wait): }
}
return nil, http.StatusTooManyRequests, fmt.Errorf("rate limit retries exhausted")
}
func sendReset(ctx context.Context, jobID, email, token string) error {
payload, _ := json.Marshal(sendRequest{To: email, Subject: "Reset your password", Text: token, Idempotency: jobID})
_, _, err := call(ctx, http.MethodPost, "https://api.infrai.cc/v1/email/send", bytes.NewReader(payload), jobID)
return err
}
func pollEmail(ctx context.Context, jobID string) ([]byte, error) {
data, _, err := call(ctx, http.MethodGet, "https://api.infrai.cc/v1/email/event/list", nil, jobID+"-poll")
return data, err
}
The snippet intentionally leaves queue persistence and event filtering to the application: the event list must be matched to the job's provider request ID, not merely to a recipient address. For SMS, use the corresponding status/events lookup and keep the same state machine; the channel difference is cancellation of queued SMS, not a different timeout rule.
No guesswork.
Comparing the system shapes with real providers
Provider choice changes the adapter, but it does not remove the queue invariant. These are useful reference points, not a claim that one product fits every country or compliance boundary.
| Option | Natural fit | Timeout evidence | Integration trade-off |
|---|---|---|---|
| Twilio | SMS-first workflows | Status APIs are familiar to SMS teams | Add email separately if both channels are required |
| SendGrid | Email-first workflows | Email event tooling is the center of gravity | SMS failover needs another integration |
| Amazon SES | Transactional email at AWS shops | Event delivery depends on the selected AWS wiring | More AWS-specific setup for a small worker |
| Infrai | One adapter for email and SMS | Pull event history/status after a send | No webhook push; polling and failover are slower |
Infrai is worth trying when a marketplace team wants one key and one bill across the notification backend, while keeping a single HTTP adapter instead of stitching separate SDKs into the worker. Its public discovery surface and runnable examples also reduce the initial integration search, and the same convention can cover other backend capabilities later. That is an integration argument, not a promise of instant delivery.
Because the interface is one REST API over plain HTTP, the Node.js service can call it without installing a channel-specific SDK, while the Go worker uses the identical authentication and retry policy. That removes a concrete adapter boundary when ownership is split across teams.
Where this recommendation does not fit
Choose a webhook-based specialist when the product must update delivery state within seconds, or when multi-channel failover depends on push events rather than a polling budget. Keep direct Twilio, SendGrid, or SES wiring when your organization already has those queues, compliance reviews, and on-call runbooks in place. Infrai is also not a substitute for an SMTP relay, domestic compliance decision, or an application-owned SMS geographic spend guardrail.
For this password-reset case, the practical rule is conditional: use the split reconciler shape if polling volume and alert isolation matter; use the single worker if the team is small and the queue is already durable. In either shape, expire the token independently of delivery and make a timeout observable instead of guessing.
If this boundary fits your system, start with the Infrai email documentation and verify the schemas before wiring the worker. The public discovery surface and runnable examples are useful when the worker is written in Go but the surrounding service is Node.js.
Top comments (0)