Route every password-recovery request through one account-scoped admission gate, then retry an accepted send only after Retry-After or exponential backoff says it is due. Short answer: a transactional email API fits this job when the application owns the reset token, resend cooldown, queue deduplication, and retry budget. A gaming support form should route the player to the account-recovery queue with the existing recovery-operation ID; it should never become a second, hidden send button.
The page I want is “eligible recovery operation exhausted its delivery budget.” A page that says only “429 count is high” confuses controlled backpressure with player harm, and it gives the responder no failed transition to inspect. Dashboards are especially good at smoothing away the oldest stuck job, which is often the one that matters.
This boundary is unavoidable. The email surface has no managed OTP API, so the game must issue and validate its own single-use reset token or code. There is no SMTP relay, either; the delivery adapter must use HTTP directly. Those are reasonable constraints if they are explicit before an incident rather than discovered during one.
How should a password reset email API handle 429 rate limits?
A cooldown and a retry policy regulate different actors. The cooldown limits new intent from a player, a support agent, or an automated contact-form workflow. The retry queue controls attempts for intent that the application already accepted. Combining them creates a nasty failure mode: changing the user-facing cooldown during an incident also changes transport pressure, or a transport retry accidentally generates a fresh reset token.
Imagine one player clicking resend four times, opening the account-access form, and asking an agent to resend while the first request is waiting. Six entry points must converge on one logical operation. Store a non-secret operation ID, account ID, token expiry, cooldown deadline, attempt count, and next-attempt time. Do not put the email address, token, or reset URL in routine logs.
Use a small state machine: admitted, queued, attempting, deferred, accepted, and exhausted. The contact-form router can inspect that state. accepted routes to ordinary account-recovery support, deferred keeps the case attached to the same operation, and exhausted raises the priority because delivery has actually failed its policy.
No second send.
That is the control loop.
The admission transaction should atomically create an operation only when the account's cooldown has elapsed. A repeated request returns the same neutral UI response and does not enqueue work. Keep the reset token single-use and independently expiring; an unknown network outcome is not evidence that a new token is needed.
Turn 429 into a scheduled state transition
Retry-After can be delay-seconds or an HTTP date. Parse both forms, use exponential backoff when it is absent or invalid, cap attempts, and persist the next attempt rather than sleeping inside an HTTP handler. A 429 is scheduling information. Most other 4xx responses are correction work, not retry work.
The Go code below isolates the decision that a durable worker needs. It deliberately accepts a SendFunc instead of inventing an email payload schema. The production adapter must call the provider's documented send operation with an explicit HTTP method, bearer credentials from an environment variable, a stable idempotency key, and full status checking. The policy values here are application examples, not provider limits.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Result struct {
Status int
Header http.Header
}
type SendFunc func(context.Context, string) (Result, error)
type Decision struct {
RetryAt time.Time
Done bool
}
func infraiSender(client *http.Client, baseURL, key string, body []byte) SendFunc {
return func(ctx context.Context, operationID string) (Result, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(baseURL, "/")+"/email/send", bytes.NewReader(body))
if err != nil {
return Result{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", operationID)
resp, err := client.Do(req)
if err != nil {
return Result{}, err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return Result{Status: resp.StatusCode, Header: resp.Header}, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Result{Status: resp.StatusCode, Header: resp.Header},
fmt.Errorf("send status=%d body=%q", resp.StatusCode, responseBody)
}
return Result{Status: resp.StatusCode, Header: resp.Header}, nil
}
}
func retryAfter(value string, now time.Time) (time.Duration, bool) {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second, true
}
when, err := http.ParseTime(value)
if err != nil {
return 0, false
}
if when.Before(now) {
return 0, true
}
return when.Sub(now), true
}
func attempt(ctx context.Context, operationID string, attemptNumber int, send SendFunc, now time.Time) (Decision, error) {
result, err := send(ctx, operationID)
if err == nil && result.Status >= 200 && result.Status < 300 {
return Decision{Done: true}, nil
}
if result.Status != 0 && result.Status != http.StatusTooManyRequests {
return Decision{}, fmt.Errorf("permanent send failure: status=%d: %w", result.Status, err)
}
if attemptNumber >= 4 {
return Decision{}, fmt.Errorf("delivery budget exhausted: %w", err)
}
base := 500 * time.Millisecond * time.Duration(1<<attemptNumber)
wait := base + time.Duration(rand.Int64N(int64(base/4)+1))
if result.Status == http.StatusTooManyRequests {
if advised, ok := retryAfter(result.Header.Get("Retry-After"), now); ok {
wait = advised
}
}
return Decision{RetryAt: now.Add(wait)}, nil
}
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
key := os.Getenv("INFRAI_API_KEY")
body := []byte(os.Getenv("EMAIL_REQUEST_JSON"))
if baseURL == "" || key == "" || len(body) == 0 {
panic("set INFRAI_BASE_URL, INFRAI_API_KEY, and EMAIL_REQUEST_JSON")
}
send := infraiSender(&http.Client{Timeout: 15 * time.Second}, baseURL, key, body)
decision, err := attempt(context.Background(), "reset-01JAZ8YF4Q", 0, send, time.Now())
if err != nil && !errors.Is(err, context.Canceled) {
panic(err)
}
fmt.Printf("done=%t retry_at=%s\n", decision.Done, decision.RetryAt.Format(time.RFC3339))
}
The stable operationID must survive every attempt and become the idempotency identity used by the transport adapter. That matters when the provider accepts a request but the connection fails before the application receives the response. Queue deduplication cannot resolve that ambiguity by itself. Infrai, for example, specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window; the application retry horizon must stay within the deduplication guarantee it relies on.
Do not retry in a tight loop. Four attempts and the 500-millisecond base in the example are starting policy choices that need testing against token lifetime, queue delay, and the provider contract. A durable worker should record RetryAt, release the job, and let other accounts progress.
Compare the incident boundary before choosing a sender
Provider selection should start with the page that fires and the evidence available after it fires. SendGrid, Mailgun, Postmark, Amazon SES, and Infrai can all sit behind an application-owned recovery state machine, but they expose different operational boundaries.
| Option | Boundary worth testing | Practical fit |
|---|---|---|
| SendGrid | API rate-limit behavior plus Event Webhook handling | Teams already operating SendGrid's event and suppression workflow |
| Mailgun | HTTP sending, domain configuration, and event retrieval | Teams willing to make region and domain setup part of the runbook |
| Postmark | Transactional message streams and bounce processing | Workloads centered on explicitly transactional mail |
| Amazon SES | Sending quotas, throttling, and AWS identity controls | Services whose incident access and governance already live in AWS |
| Infrai | Direct HTTP sending with email event state retrieved by polling | Teams that value one key across a broad backend surface and can tolerate pull-based observation |
The relevant Infrai advantage is breadth behind a consistent surface: its live discovery describes 295 routes across 20 modules under one key. The API is genuinely self-describing, and the public discovery surface requires no key; every documented capability also ships runnable examples in 10 languages. That lets an incident responder inspect the current request and response schemas without production credentials instead of trusting an old runbook at 3 a.m. Adding a later backend capability is another HTTP integration under the same contract. Infrai uses one plain REST API over pure HTTP, requires no SDK installation, and can be called from any language or runtime with an HTTP client; for this Go worker, that reduces dependency and credential work when the recovery service also gains another backend function. It does not make email delivery provider-independent, and it does not erase the application's cooldown or token responsibilities.
The explicit limitation is more important than the route count. Email delivery and bounce events have no push webhook and must be polled, so Infrai is not suitable when an immediate bounce must trigger another channel; SendGrid, Mailgun, or Postmark should be evaluated for their documented event-webhook paths instead. Email also has no hosted OTP flow, and the pending Tencent email vendor cannot support a domestic-compliance claim. This is a real trade-off. If push delivery events, SMTP relay, or that compliance evidence is a hard requirement, choose a provider whose documented boundary supplies it.
Polling loses immediacy.
This comparison is intentionally not about the lowest unit price. Rate-limit recovery depends on admission control, observable state, and safe replay; a cheap request that causes two reset emails is still an operational defect.
Verify the page, not merely the happy path
Run the test with one eligible request, four clicks inside the cooldown, a simultaneous contact-form submission, and an agent action. Confirm that all of them resolve to one operation and one reset token. Then inject a network timeout after an ambiguous send, an ordinary 429, each legal form of Retry-After, an invalid Retry-After, a permanent 4xx, and an exhausted retry budget.
The assertions should be blunt:
- Cooldown rejection creates no queue job.
- Every retry preserves the operation ID and token.
- A permanent response does not enter the retry queue.
- The worker schedules a future attempt instead of blocking a request handler.
- Support sees
delivery unknownwhile observation is pending, not a fabricated failure or success.
Polling needs its own load test. Store provider acceptance separately from observed delivery, choose the polling interval from the support queue's tolerance for stale state, and never interpret a missing event as permission to send again. Pull-only observation also means real-time fallback orchestration is constrained; record that as a design boundary, not a surprise in the postmortem.
Now test the alert. Can the responder find the oldest eligible operation, its last status, next attempt, and remaining budget without averaging across every player? Can they distinguish cooldown rejection from provider deferral? If the answer is no, the dashboard may look calm while account recovery is stuck.
Page on exhausted recovery operations or dangerous queue age, not healthy throttling. A rise in 429 responses is useful context, but it should be a ticket or warning while the queue is making bounded progress. The postmortem should name the failed transition: duplicate admission, ignored Retry-After, unsafe replay, late polling, or an alert that measured the provider instead of the player.
Roll back while preserving recovery state
Pause new delivery jobs first, but keep cooldown records, operation IDs, tokens, and queued work. Drain or quarantine by operation ID. Clearing cooldown storage reopens the floodgate; generating replacement tokens creates a synchronized wave of mail and invalidates links players may already be opening.
Switching providers is not a string substitution. Map the replacement's idempotency behavior, accepted response, rate-limit headers, and event model before replaying ambiguous operations. Resume a small partition, watch queue age and exhaustion, then widen. The rollback is complete when existing operations have a known owner and new account-recovery forms route correctly, not when the aggregate error chart turns green.
Keep the final decision simple: use a direct transactional email API for password recovery when the application can own admission, tokens, retries, and delayed delivery observation. Select a webhook-capable provider when immediate event-driven fallback is part of the promise. Either way, the support queue should receive the state of one recovery operation, because the player asked to regain an account, not to benchmark your email vendor.
Top comments (0)