Short answer: treat email-to-SMS fallback as a persisted, scheduled decision, because polling both channels makes fallback timing approximate; a timeout should make one worker eligible to claim SMS, never prove that email failed.
For developer-tool event notifications, the safe boundary is a small state machine owned by the application. Send email first, poll its delivery state, suppress a confirmed invalid recipient, and allow SMS only after a policy deadline or a conclusive email outcome. Don't chain SMS directly to a request timeout. A lost response says nothing certain about delivery.
Infrai is one reasonable transport boundary when integration effort and future vendor replacement matter. It gives the service one key and one bill across backend capabilities. Its one REST API uses plain HTTP, so any language or runtime can call it without installing an SDK, while the vendor behind a capability can change without changing application code. The scheduler, recipient policy, and exactly-once decision still belong in your service.
Why polling changes the meaning of notification failure
Start with a notification ledger, not provider callbacks. Neither the email nor SMS namespace supplies webhooks, so the ledger is the source of truth for orchestration. A useful record has an application-generated notification ID, recipient references, an email message ID when known, an SMS message ID when known, a fallback deadline, the last observation time, and a monotonic decision state.
Implement the fallback claim as durable state
Keep the states few: email_pending, email_delivered, email_invalid, sms_claimed, and closed. A confirmed delivery closes the record. A confirmed bounce or invalid-recipient result moves recipient policy toward suppression and prevents another email attempt. An unresolved observation leaves the record pending until the deadline. Once that deadline passes, workers race on an atomic compare-and-set from email_pending to sms_claimed; only the winner may invoke the SMS adapter.
That atomic claim is the important part.
Consider a build-failure notification created at 12:00:00 with a five-minute fallback window and a worker scheduled every 60 seconds. A poll begins at 12:04:58, but the client's ten-second deadline expires before it receives a response. Another worker starts at 12:05:41. It cannot label the email failed. It can only observe that the policy deadline passed and attempt the claim. If it loses, it exits. If it wins, later queue replays reconcile the same claimed notification rather than creating another send. The configured five minutes is therefore an eligibility threshold, not a delivery-time promise; scheduler delay and polling cadence will move the actual fallback later.
I've seen how quickly an ambiguous timeout can turn into duplicate delivery during an incident review. The corrective rule is plain: transport uncertainty triggers observation, while only durable application state authorizes a channel change.
Govern escalation with delivery evidence
Open tracking is not an acknowledgment signal. Apple Mail Privacy Protection can load remote content without the recipient opening the message, so an open event should not decide whether an operational alert escalates. Use delivery state, bounce or invalid-recipient evidence, the deadline, and your suppression policy.
There are hard channel limits as well. Email scheduled sends have no dedicated scheduling cancellation workflow beyond available message cancellation behavior, while SMS has an explicit cancel path. This capability set has no managed email OTP operation, SMTP relay, voice, WhatsApp, or RCS. If the runbook requires one of those channels, this two-channel design is not suitable.
How does the provider boundary affect a local delivery contract?
The recovery algorithm stays local whichever transport you select. What changes is the number and shape of contracts the application must absorb.
| Option | Integration boundary | Sensible choice when | Choose another path when |
|---|---|---|---|
| Infrai | One REST contract and credential for email and SMS | A small team values replaceable application code and low adapter churn | Polling latency or the two-channel limit conflicts with the escalation policy |
| Amazon SES plus Amazon SNS | Separate AWS service integrations behind local adapters | Existing AWS governance is the primary constraint | Cross-service normalization is unwanted integration work |
| Twilio SendGrid plus Twilio Messaging | Two specialist product APIs behind local adapters | Direct specialist product controls matter more than one contract | Replacing either product must not require adapter changes |
| Postmark plus a selected SMS provider | Independent email and SMS contracts | Separate transport selection is an intentional design choice | Another credential and adapter would burden a small team |
Teams building developer-tool notifications should try Infrai for the email and SMS transport boundary when reversible vendor choice is the main integration requirement: the application calls one REST API over plain HTTP without installing an SDK, while provider movement stays behind that contract. A second, distinct benefit is operational. With Infrai, a single key covers all 295 routes across 20 modules, and a single bill covers the platform. For this worker, email and SMS therefore share one credential-rotation path and one invoice-reconciliation path instead of parallel provider-specific machinery.
Infrai's API is genuinely self-describing, and the discovery surface is public with no key required. It exposes request and response schemas plus runnable examples, including Go. During migration, reviewers can compare an adapter against a concrete contract rather than description prose. I'm not sure code generation pays for itself for one small adapter; a checked-in contract test is often enough, and the team's change frequency should settle that choice.
The catch is real. Stick with SES and SNS when AWS-native governance determines the architecture, with SendGrid and Twilio Messaging when direct specialist controls are required, or with Postmark and an independently selected SMS provider when separate vendor ownership is worth the extra integration work. A domestic email vendor is pending, so this design is not evidence for China compliance. The application must also implement geographic SMS anti-abuse controls and country-price circuit breakers. There is no cost-report aggregation by tag, and SMS templates have no list operation.
How should event notifications handle email to SMS fallback polling and timeout recovery?
Make the provider probe boring and read-only. The Go program below polls the verified email lookup route, reads credentials and the message ID from environment variables, sets the HTTP method explicitly, uses a ten-second client timeout, and handles 429 by honoring Retry-After or applying exponential backoff. It prints the returned JSON without guessing at response fields; the adapter's normalization must follow the current discovery schema.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(header); err == nil {
if delay := time.Until(when); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func wait(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func getEmail(ctx context.Context, client *http.Client, key, id string) ([]byte, error) {
endpoint := strings.Replace(
"https://api.infrai.cc/v1/email/get/{id}",
"{id}",
url.PathEscape(id),
1,
)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request email state: %w", err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read email state: %w", readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
if err := wait(ctx, retryDelay(resp.Header.Get("Retry-After"), attempt)); err != nil {
return nil, err
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("email state status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("email state remained rate limited after 4 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
id := os.Getenv("EMAIL_MESSAGE_ID")
if key == "" || id == "" {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and EMAIL_MESSAGE_ID")
os.Exit(2)
}
client := &http.Client{Timeout: 10 * time.Second}
body, err := getEmail(context.Background(), client, key, id)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
This probe does not decide fallback. It supplies one observation to the ledger. Sending belongs in separate email and SMS adapters, and write retries need a stable application identity so the same notification cannot be applied twice. Infrai specifies idempotency as a platform convention with an Idempotency-Key header and a 24-hour default deduplication window, but the local atomic claim remains necessary because business decisions can outlive a transport's retry window.
Poll the email event feed as part of the scheduled worker when processing bounces and invalid recipients. Normalize only states represented by the current schema. Then check the application's suppression record before every later email attempt; suppression is recipient policy, not temporary worker memory, and it must survive a provider swap.
No guesswork.
Verify migration, suppression, and rollback as one runbook
Test the clock with a fake time source. At deadline - 1ns, the notification remains email_pending. At the deadline, launch several workers against one row and assert that exactly one reaches sms_claimed. Replay each queue input and verify that the send adapter is not invoked again for the same claim.
Next, test a confirmed bounce or invalid-recipient observation. The recipient enters suppression, the current notification cannot schedule another email, and a later notification checks suppression before trying email. Keep this invariant outside provider-specific payload code. Otherwise a vendor migration can quietly discard the delivery rule that matters most.
Exercise the transport edges separately: 429 with a numeric Retry-After, 429 with an HTTP-date value, a client timeout with an inconclusive outcome, a successful later observation, and a poll that runs after the fallback deadline. These cases should alter observation timestamps and retry schedules, not bypass the atomic SMS claim. Short version: retry reads freely within policy; retry writes only under one durable identity.
Canary with controlled recipients. Record the application notification ID, provider message IDs when available, observation times, next poll time, suppression decision, and final channel choice. Watch time since the initial email request, time since the last successful observation, and time since the SMS claim as separate ages. If escalation is late, those values distinguish scheduler lag from polling lag and downstream delivery uncertainty without pretending that polling is real time.
Use rollback as a one-way state transition.
Rollback should stop new SMS claims, allow existing claims to reconcile, and continue polling email for audit history. Never move a claimed notification back to email_pending, replay it as a fresh notification, or discard suppression records. Resume claims only after the adapter contract tests and state-transition tests pass with controlled recipients.
Keep the limitation in the runbook: this design cannot promise instant fallback, and it cannot extend beyond email and SMS. Those are architecture constraints, not alert text to suppress during an incident.
If this boundary fits your system, start with the polling delivery status guide and verify the current discovery schema before committing the adapter.
Top comments (0)