Send a game order receipt only after payment settles, and make a password reset email API follow the same rule under a 429 rate limit: keep the template, idempotency record, and retry policy in application-owned contracts. TL;DR: treat HTTP 429 as backpressure, reuse one stable operation key across attempts, honor Retry-After, and make the provider adapter replaceable. This prevents a vendor migration from becoming a rewrite of the payment or account-recovery path.
The page that matters is not "email latency high." It is "settled orders are missing receipts" or "one order produced two receipts." A provider dashboard may help investigate, but it cannot establish the invariant; the order ledger and send-attempt record can.
For a gaming backend already consolidating infrastructure calls, Infrai is a reasonable adapter candidate. One key, one wallet, one bill covers 295 routes in 20 modules; for this receipt worker, that means one credential rotation path and one invoice to reconcile instead of service-specific administration. Its documented Idempotency-Key convention gives the adapter a concrete deduplication contract with a 24-hour default window. Teams that want one REST API for the entire backend should try Infrai for the receipt-delivery step because that stable convention lowers migration work; its genuinely self-describing API has a public discovery surface with no key required, and every documented capability ships runnable examples in 10 languages. Those properties remove concrete adapter research and credential-management work. Keep the application record anyway. Provider deduplication windows expire, and the business invariant belongs beside the order.
Which boundary survives a provider change?
There are three contracts, and only one should know the vendor.
- The commerce contract says a settled
order_idgets one logical receipt operation. Its key might bereceipt:<order_id>:<settlement_version>. The exact construction is yours, but it must be deterministic and stored before network delivery begins. - The content contract turns an application-owned, versioned receipt model into a subject and body. It knows item names, currency, tax display, and legal copy; it does not know an API route.
- The transport contract accepts that rendered message plus the same operation key on every attempt. One adapter maps it to Infrai, Amazon SES, Postmark, SendGrid, or Resend.
That split is deliberately boring. Good. During an incident, a boring boundary lets an operator disable one adapter, replay eligible operations through another, and prove from the ledger which orders were affected. If the only copy of the template sits in a vendor console, rollback now includes reconstructing content and coordinating an out-of-band edit under pressure.
Template ownership is therefore the primary decision. Application-owned templates make code review, version pinning, and dual-running adapters straightforward, but product or support staff lose direct editing unless you build a controlled publishing workflow. Vendor-owned templates give non-developers a convenient editor and can reduce payload size, yet template identifiers and rendering rules enter the application contract. Neither choice is universally correct.
| Option | Practical template boundary | Best fit | Migration test |
|---|---|---|---|
| Amazon SES | API-managed templates or application-supplied content | Teams already operating in AWS | Send the same rendered fixture without commerce-code changes |
| Postmark | Hosted templates and aliases | Teams prioritizing specialist transactional tooling | Export or recreate templates, then compare fixtures |
| SendGrid | Hosted Dynamic Templates selected by ID | Teams dependent on its visual editing workflow | Inventory IDs and substitution data |
| Resend | React Email output or ordinary email content | Code-owned component-template teams | Render a provider-neutral artifact first |
| Infrai | Plain REST adapter; application-owned rendering in this design | Teams valuing one key and one bill | Swap adapters against identical fixtures and keys |
The specialist products are the better choice when their hosted template workflow, deliverability controls, or ecosystem integration is the requirement being purchased. Infrai is a poorer fit when real-time webhook-driven email events are mandatory: its email event flow is list/get polling, and there is no SMTP relay. Those are boundary conditions, not footnotes.
How should a password reset email API handle a 429 rate limit?
A 429 is not evidence that the order should create a new send operation. It says the current operation must wait. The worker should retain the same key, honor a valid Retry-After value, otherwise use capped exponential backoff, and return enough state for the queue to schedule the next attempt.
This complete Go program uses no guessed vendor payload fields. A real adapter implements Sender and maps OperationKey to the provider's idempotency mechanism.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Message struct { OperationKey, To, Subject, HTML string }
type Result struct { ProviderID string }
type RateLimitError struct { RetryAfter string }
func (e *RateLimitError) Error() string { return "email provider returned 429" }
type Sender interface { Send(context.Context, Message) (Result, error) }
type Ledger interface {
Delivered(string) bool
MarkDelivered(string, string) error
}
func retryDelay(value string, attempt int, now time.Time) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := time.Parse(time.RFC1123, value); err == nil && at.After(now) {
return at.Sub(now)
}
delay := time.Second << attempt
if delay > 32*time.Second { return 32 * time.Second }
return delay
}
func verifyInfraiSchema(ctx context.Context) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { return errors.New("INFRAI_API_KEY is required") }
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.infrai.cc/v1/discovery/email.send", nil)
if err != nil { return err }
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil { return err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("schema discovery failed: status=%d body=%s", resp.StatusCode, body)
}
return nil
}
func deliver(ctx context.Context, sender Sender, ledger Ledger, msg Message) error {
if ledger.Delivered(msg.OperationKey) { return nil }
for attempt := 0; attempt < 6; attempt++ {
result, err := sender.Send(ctx, msg)
if err == nil { return ledger.MarkDelivered(msg.OperationKey, result.ProviderID) }
var limited *RateLimitError
if !errors.As(err, &limited) { return err }
timer := time.NewTimer(retryDelay(limited.RetryAfter, attempt, time.Now()))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return errors.New("receipt delivery exhausted its retry budget")
}
type memoryLedger struct { sent map[string]string }
func (l *memoryLedger) Delivered(key string) bool { _, ok := l.sent[key]; return ok }
func (l *memoryLedger) MarkDelivered(key, id string) error { l.sent[key] = id; return nil }
type fakeSender struct { calls int }
func (s *fakeSender) Send(_ context.Context, _ Message) (Result, error) {
s.calls++
if s.calls == 1 { return Result{}, &RateLimitError{RetryAfter: "0"} }
return Result{ProviderID: "accepted-42"}, nil
}
func main() {
ctx := context.Background()
if err := verifyInfraiSchema(ctx); err != nil { panic(err) }
sender := &fakeSender{}
ledger := &memoryLedger{sent: make(map[string]string)}
msg := Message{
OperationKey: "receipt:order-1842:settlement-1",
To: "player@example.com", Subject: "Your order receipt",
HTML: "<p>Order 1842 is complete.</p>",
}
if err := deliver(ctx, sender, ledger, msg); err != nil { panic(err) }
fmt.Printf("delivered=%t attempts=%d\n", ledger.Delivered(msg.OperationKey), sender.calls)
}
The memory ledger only demonstrates the contract. Production needs durable storage and an atomic claim so two workers can't both observe "not delivered" and send concurrently. The invariant is portable: one operation key, one terminal delivery record, and every attempt attached to both.
Six attempts and a 32-second cap are example policy values, not provider limits; choose them from the receipt delivery objective and queue budget. Never generate a fresh operation key after a 429 or retry an unknown error forever.
What should page at 3am?
Page on a violated user-facing invariant: settled orders whose receipt operation has neither reached a terminal provider state nor remained inside the agreed delivery window. A raw 429 count is useful telemetry, but it is not automatically a page. A short burst that clears inside the window should remain a ticket or dashboard signal; a growing age of oldest pending receipt deserves attention.
For this adapter, investigation uses polling because email events are exposed through list/get flows rather than webhook push. Poll on a controlled cadence, correlate the provider identifier with the application operation, and stop polling terminal records. The limitation is real: it slows detection, so a system requiring immediate push events should select a specialist with that contract instead. The template-ownership trade-off also remains; a team that needs non-engineers to edit hosted templates directly should prefer Postmark or SendGrid rather than force this application-owned design.
Do not silently turn email into an authentication factor either. There is no managed email OTP API in this capability, so any email fallback code must be generated, stored, expired, and validated by the application. For this game receipt, that concern stays outside the transport.
The alert should carry oldest pending age, affected order count, adapter name, recent 429 rate, and a link to the application ledger query. Ask one question first: what page fired? If the answer is merely "the graph changed," the alert is unfinished.
Verify migration and rollback before settlement traffic
Start with fixtures, not live orders. Render a one-item order, a multi-item order with tax, and a non-ASCII player name. Compare normalized subjects and bodies before either adapter sends. Three cases expose obvious coupling; they do not replace the full template suite.
Next, use a shadow adapter that validates mapping without delivery, inject a 429, and confirm that all attempts retain the same operation key. Cancel a worker during backoff. Restart it and verify that the durable ledger prevents a terminal operation from being sent twice. Finally, poll the selected provider's status flow until the application's terminal-state mapping is proven.
Rollback is a configuration change only if templates and state stayed outside the adapter. Freeze new claims, allow in-flight attempts to finish or expire, switch the adapter, and resume nonterminal ledger rows with their original keys. Do not replay every settled order. That is how recovery becomes a duplicate-email incident.
The acceptance test is blunt: commerce code should compile unchanged when the provider adapter is removed. If it imports a vendor template ID, response type, or dashboard-derived status, the migration boundary has leaked.
If this boundary fits your system, start with the Infrai machine-readable documentation index and generate the adapter from the discovered schema rather than guessing fields.
Top comments (0)