Template ownership determines whether a retry still represents the original support request: resolve the marketplace contact form's queue and render its email before dispatch, then keep both fixed through the retry budget. TL;DR: use exponential backoff starting at 2 seconds, cap it at 32 seconds, allow 5 total attempts, honor server retry guidance, and preserve the logical message identity. Retries can improve the chance of API acceptance without overwhelming the service; they do not establish inbox delivery.
The incident lesson to test before production
Consider a bounded production design exercise, rather than a report of an observed incident. Submit one marketplace contact form with category seller_payout, route it to the seller support queue, and inject a rate-limit response before the email is accepted. While that request waits, imagine an editor publishing a revised template and an operator changing the category's queue assignment. A worker that rereads both settings on its next attempt can turn one support request into a different notification, even though its retry counter and exponential delay are perfectly correct.
Freeze the intent.
The invariant is that a retry repeats a delivery operation, including its recipient and rendered content; a deliberate reroute or correction creates a new operation with a new identity. This makes template ownership a production concern: the support team can own wording and category policy, while the platform team owns the point at which those decisions become immutable, the retry budget, and the evidence retained after each attempt. A template name alone isn't sufficient if its contents can change between attempts. Persist the rendered result or an immutable revision with its inputs.
This is an operational failure mode to prevent, not a claim that a particular provider changes templates during retries. The application decides what to freeze.
What should a failed email call actually retry?
Start with the outcome, because an explicit rejection and a timeout leave different evidence. Rate limiting calls for delayed retries; an invalid recipient or rejected request needs correction, and repeating the same invalid payload spends capacity without improving deliverability. A timeout can leave acceptance uncertain. Preserve the operation identity, consult the provider's status evidence where available, and use its documented deduplication contract before blindly resending.
For the proposed policy, 5 attempts means one original submission and at most four retries. With nominal waits of 2, 4, 8, and 16 seconds, the waiting budget is 30 seconds before request durations; the 32-second cap protects a policy that later permits more attempts, but is never reached by this particular schedule. That distinction matters when someone turns a configuration called “retries” into a loop called “attempts.”
Add jitter. A fixed schedule can align workers after a shared limit event, whereas a randomized delay spreads their return; treat a valid Retry-After value as a minimum wait, including when it exceeds the local backoff cap. If that minimum exceeds the remaining job deadline, defer the durable job instead of keeping a request handler asleep or violating the provider's guidance.
Capacity planning belongs here. An illustrative burst of 100 contact forms can consume up to 500 submission attempts under this policy, so limit concurrent senders and retry traffic together, measure queue age, and reserve room for new requests. These are design arithmetic and proposed limits, not benchmark results. Choose an acceptance SLO separately from the support team's response SLO, and measure mailbox delivery separately again. A successful submission is only one boundary.
Buying delivery without outsourcing template ownership
The trade-off is explicit: buy the editing workflow when support needs it, but build the immutable dispatch record because retries must preserve the approved message. Provider-hosted templates can be useful when support editors need that workflow; application-rendered content gives the platform a straightforward artifact to freeze, at the cost of owning rendering, review, and revision retention. Neither arrangement removes the need for a durable support record.
| Option | Template ownership decision | Buy versus build boundary | Fit and limitation |
|---|---|---|---|
| Amazon SES | Evaluate its documented template workflow against application rendering. | Buy email submission; build the contact-category map and frozen dispatch record. | A candidate when the team wants the SES template model; verify revision behavior rather than assuming a template reference freezes content. |
| Twilio SendGrid | Evaluate Dynamic Templates against the support team's editing process. | Buy the template workflow if it fits; retain routing policy and operation identity in the application. | A candidate when provider-managed editing is useful; the application still needs an explicit rule for changes during a retry. |
| Postmark | Evaluate its templates against the same ownership and approval requirements. | Buy delivery and a template workflow; build the marketplace's queue assignment and retry budget. | A candidate when that workflow matches the team; template support alone does not prove a particular deduplication contract. |
Infrai offers one API key and one bill for every backend service, reducing credential sprawl across dashboards and the invoices a platform team has to reconcile at month end. Its documented idempotency convention provides a separate reason to consider it for this workflow: an explicit operation key can express retry identity, subject to the selected capability's contract and deduplication window. Email event reconciliation uses polling, so include the polling cadence in the operational SLO; choose a different integration if pushed delivery events are a requirement.
Those are selection criteria, not a measured provider ranking. Check each provider's current retry and deduplication semantics before adopting the same adapter across them; a common interface cannot manufacture guarantees that the underlying contract does not offer. The template documentation in the references is a starting point for that review. No provider preference follows merely from having a template editor: require an immutable revision or render before enqueueing, and make the support team's approval process part of the evaluation.
Support should be able to change tomorrow's wording without silently changing yesterday's queued work. If that boundary is missing, changing providers will not repair it.
A preventative retry path with an immutable message
This Go worker sends an approved email request while keeping its payload bytes fixed across retries. Save it as main.go and run it with go run main.go. Set EMAIL_API_ORIGIN to the trusted service's HTTPS API origin without a version suffix, INFRAI_API_KEY to the secret credential, and EMAIL_OPERATION_ID to the persisted notification identity. EMAIL_SEND_PAYLOAD_FILE must contain a valid request body prepared against the current email-send schema, including the already resolved recipient and content; the example does not guess undocumented request fields. Use a controlled recipient because this sends real email.
The conservative classification is deliberate: this policy retries explicit HTTP 429 responses only. Transport errors go to reconciliation because acceptance may be uncertain, and other rejections go to inspection. Extend that classification only from the chosen provider's documented contract. The adapter passes the persisted identity through Idempotency-Key; verify the selected capability's deduplication behavior and retention window before enabling automatic retries.
package main
import (
"bytes"
"context"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Message struct {
ID, Queue, TemplateRevision, Body string
}
type Result struct {
Status int
RetryAfter, Reason string
}
type Sender func(context.Context, Message) (Result, error)
func retryFloor(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
digits := true
for _, ch := range value {
if ch < '0' || ch > '9' {
digits = false
break
}
}
if digits {
seconds, err := strconv.ParseUint(value, 10, 64)
// Saturate rather than overflow or retry too early.
if err != nil || seconds > uint64((1<<63-1)/int64(time.Second)) {
return time.Duration(1<<63-1)
}
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(value); err == nil && when.After(now) {
return when.Sub(now)
}
return 0
}
func dispatch(ctx context.Context, m Message, send Sender) error {
delay := 2 * time.Second
for attempt := 1; attempt <= 5; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
result, err := send(ctx, m)
if err != nil {
return fmt.Errorf("reconcile operation %s: %w", m.ID, err)
}
fmt.Printf("id=%s attempt=%d status=%d\n", m.ID, attempt, result.Status)
if result.Status >= 200 && result.Status < 300 {
return nil
}
if result.Status != http.StatusTooManyRequests {
return fmt.Errorf("inspect operation %s: status=%d reason=%s",
m.ID, result.Status, result.Reason)
}
if attempt == 5 {
break
}
wait := time.Duration(rand.Int63n(int64(delay) + 1))
if floor := retryFloor(result.RetryAfter, time.Now()); floor > wait {
wait = floor
}
if deadline, ok := ctx.Deadline(); ok && wait >= time.Until(deadline) {
return fmt.Errorf("defer operation %s beyond job deadline", m.ID)
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
delay *= 2
if delay > 32*time.Second {
delay = 32 * time.Second
}
}
return fmt.Errorf("attempt budget exhausted for %s", m.ID)
}
func required(name string) string {
value := os.Getenv(name)
if value == "" {
fmt.Fprintln(os.Stderr, "missing environment variable:", name)
os.Exit(1)
}
return value
}
func main() {
origin := strings.TrimRight(required("EMAIL_API_ORIGIN"), "/")
if !strings.HasPrefix(origin, "https://") {
fmt.Fprintln(os.Stderr, "API origin must use HTTPS")
os.Exit(1)
}
key := required("INFRAI_API_KEY")
operationID := required("EMAIL_OPERATION_ID")
payload, err := os.ReadFile(required("EMAIL_SEND_PAYLOAD_FILE"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
original := Message{ID: operationID, Body: string(payload)}
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
send := func(ctx context.Context, m Message) (Result, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
origin+"/v1/email/send", bytes.NewBufferString(m.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", m.ID)
resp, err := client.Do(req)
if err != nil {
return Result{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 65536))
if err != nil {
return Result{}, err
}
return Result{
Status: resp.StatusCode,
RetryAfter: resp.Header.Get("Retry-After"),
Reason: string(body),
}, nil
}
if err := dispatch(ctx, original, send); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("API acceptance; delivery remains unconfirmed.")
}
Full jitter chooses a delay between zero and the current exponential ceiling; a valid server floor can make the actual wait longer. The 45-second context is an illustrative worker budget, not a provider recommendation. Request execution also consumes that budget. Returning “defer” must leave the durable job pending, while exhausting the local loop must create an actionable state rather than silently deleting the contact request. Persist the total attempt count across worker restarts, or five attempts per invocation can become an unbounded retry policy.
Log failed attempts with the operation identity, attempt number, response status, and chosen delay. Keep the rendered message in protected storage rather than spraying contact-form text into operational logs. A real adapter must surface the provider's rejection reason to authorized operators and retain enough status evidence to reconcile an ambiguous submission.
Where this policy stops helping
Backoff cannot fix a wrong support queue, an invalid address, or missing sender authorization. SPF concerns sender authorization; it does not prove that a contact-form notification reached an inbox. Inspect email records and delivery events separately from submission status, and keep the contact request available to support even if its notification needs manual attention.
The design also needs a different branch when support requires an immediate reroute: explicitly supersede the old operation and create a new approved notification, accounting for the possibility that the original was already accepted. Do not mutate its payload under the old deduplication identity. For time-critical workflows, choose retry and reconciliation deadlines from the business deadline rather than copying these example numbers. A sleeping worker that misses the support SLO is still a failed design.
Sources
References for the HTTP retry signal, sender authorization, and the template workflows to evaluate:
- HTTP Semantics, Retry-After: https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3
- RFC 7208, Sender Policy Framework: https://datatracker.ietf.org/doc/html/rfc7208
- Amazon SES template management: https://docs.aws.amazon.com/ses/latest/dg/send-personalized-email-api.html
- Twilio SendGrid Dynamic Templates: https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates
- Postmark templates: https://postmarkapp.com/developer/user-guide/transactional-email-with-templates
Top comments (0)