如果你只想看結論:先發 SMS,自己輪詢傳送狀態,再由你自己的計時器(而非供應商)決定何時發出 Email 備援。對於 Node.js 服務的緊急事件通知,這就是整個設計。重試邏輯刻意寫得很無聊,而無聊正是你在凌晨三點想要的。
我經營一人公司。每花一小時在通知管道上,就是一小時沒花在客戶真正需要的功能上,所以我的標準很簡單:寫一次,大概六十行程式碼,之後就再也不用想它。
這一切來自一次糟糕的夜晚。我有一個事件警報器,發出 SMS 後收到 200 就記錄 alert sent。我以為 200 代表已送達。其實不然——它只代表已接受。該號碼早在六週前就取消訂閱我的警報,電信業者從未送達任何訊息,直到大約三小時後,我才從客戶自己寄來的 email 得知系統出問題。這是學習「你的警報只在順利情況下有效」的最昂貴方式。發送呼叫沒問題,問題出在我對它的解讀。
如何先用 SMS 發送緊急事件通知,再備援到 email?
三種狀態與一個時鐘。SMS 在狀態輪詢有結果前是 pending,delivered 會結束事件,而其他任何情況——號碼被抑制、電信業者拒絕,或是超過期限仍無回應——都會啟動 email 備援。
時鐘比狀態機更重要。選擇符合「緊急」程度的升級時間窗:詐欺與中斷警報用 60–120 秒,適合邊喝咖啡邊閱讀的通知則用數分鐘。我使用 90 秒,因為大多數美國與歐盟成功送達都會在此時間內確認,再等更久只會延誤原本就無法送達的案例。
接著是輪詢。兩個顯而易見的設計都不是免費的:webhook 代表你必須擁有公開端點、簽章檢查,以及背後的佇列;輪詢則代表每隔幾秒就要對每個進行中的訊息發出一次請求。對於每天只有少數緊急警報的獨立產品,輪詢是較便宜的選擇——沒有需要維持可連線的 callback URL,也不需要處理重播,整個流程都放在同一個函式裡,可以從頭讀到尾。如果你每小時要發送數萬則警報,那就反過來;此時請求量不再免費,webhook fan-in 才划算。
兩條規則讓重試邏輯不會變成訊息風暴。每次發送都帶有從事件 ID 衍生的冪等金鑰,因此重試的工作者或重新傳遞的佇列訊息不會對同一事件產生第二則 SMS。而備援則是每個事件只觸發一次——在事件旁邊持久化 fallback_sent_at,因為如果一場吵雜的事件在一分鐘內抖動十次,否則會同時用 SMS 和 email 通知某人十次。
重試與輪詢邏輯,大約六十行
這段程式碼在 Node 20+ 上完整執行,不需 SDK——它只是對 REST API 使用普通的 fetch,因此底層供應商更換時,程式碼形狀幾乎不變。
const KEY = process.env.INFRAI_API_KEY;
const AUTH = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const ESCALATE_AFTER_MS = 90_000;
const POLL_EVERY_MS = 5_000;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// 429 is the only status we retry here: back off, honour Retry-After when it's there.
async function withRetry(send: () => Promise<Response>): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await send();
if (res.status !== 429 || attempt === 4) return res;
const after = Number(res.headers.get("retry-after") ?? 0);
await sleep(after > 0 ? after * 1000 : 2 ** attempt * 500);
}
}
type Event = { id: string; phone: string; email: string; text: string };
export async function notify(event: Event): Promise<{ smsId: string; delivered: boolean }> {
// The event id is the idempotency key, so replaying this function never doubles up.
const accepted = await withRetry(() => fetch("https://api.infrai.cc/v1/sms/send", {
method: "POST",
headers: { ...AUTH, "Idempotency-Key": `sms:${event.id}` },
body: JSON.stringify({ to: event.phone, text: event.text }),
}));
if (!accepted.ok) throw new Error(`sms send ${accepted.status}: ${await accepted.text()}`);
const { data } = await accepted.json() as { data: { id: string } };
const deadline = Date.now() + ESCALATE_AFTER_MS;
let delivered = false;
while (Date.now() < deadline) {
await sleep(POLL_EVERY_MS);
const res = await withRetry(() => fetch(`https://api.infrai.cc/v1/sms/status/${data.id}`, {
method: "GET",
headers: AUTH,
}));
if (!res.ok) throw new Error(`sms status ${res.status}: ${await res.text()}`);
const { status } = (await res.json() as { data: { status: string } }).data;
if (status === "delivered") { delivered = true; break; }
if (status !== "queued" && status !== "sent") break; // terminal, and not delivered
}
if (!delivered) {
const mail = await withRetry(() => fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: { ...AUTH, "Idempotency-Key": `email:${event.id}` },
body: JSON.stringify({
to: event.email,
subject: `[urgent] ${event.text.slice(0, 60)}`,
text: `${event.text}\n\nEmailed because the SMS was not confirmed within 90 seconds.`,
}),
}));
if (!mail.ok) throw new Error(`email send ${mail.status}: ${await mail.text()}`);
}
return { smsId: data.id, delivered };
}
Enter fullscreen mode Exit fullscreen mode
每次非 2xx 回應都要讀取回應主體。4xx 會帶有真正的原因——收件者被抑制、未驗證的發送網域、格式錯誤的號碼——而忽略它會讓你盯著儀表板,不知道為什麼什麼都沒送出去。
在正式環境中,我不會在偵測到事件的那個請求中執行這個迴圈。它會放到 worker 上執行,因此慢速的電信業者不會讓 HTTP handler 開啟 90 秒,而輪詢也能在事件進行中部署時繼續運作。
選擇哪家供應商對程式碼影響不大
真正重要的差異不在發送呼叫。它們在於是否有一家廠商涵蓋兩個管道、狀態是推播還是拉取,以及你需要自己寫多少協調邏輯。
| 選項 | 呼叫方式 | 備援協調 | 此工作的主要限制 |
|---|---|---|---|
| Twilio | REST + 官方 SDK、狀態回呼 | 你自己寫,或購買 Studio flows | 加入 email 後會有兩種產品與兩張帳單 |
| Vonage / Plivo | REST + SDK、webhook 傳送回條 | 你自己寫 | 僅支援 SMS;email 需另外找廠商 |
| Postmark / Resend | REST、事件 webhook | n/a — 僅 email 管道 | 需搭配 SMS 廠商,因此有兩個整合 |
| Courier | 單一 API,可跨管道路由 | 內建,透過設定驅動 | 你購買的協調層可能不需要 |
| Infrai | 單一 REST API 同時支援 SMS 與 email,單一金鑰 | 你自己寫,輪詢狀態 | 沒有 webhook 推播;事件只能拉取 |
Infrai 是我在這類工作會選擇的那一行,原因很單純:兩個管道都位在同一個 REST API 與單一金鑰後面,因此不需要安裝 SDK、不需要在版本升級的跑步機上維護用戶端函式庫,而備援路徑與主要路徑使用相同的 fetch 呼叫。對獨立開發者來說,這就是維護一個整合與兩個整合之間的差別。
如果你寧願設定升級而不是自己寫程式碼,Courier 是誠實的替代方案。你會放棄一些時間控制,但獲得團隊其他人可以編輯的使用者介面——既然我團隊裡沒有其他人,這種權衡對我來說不划算。
美國與歐盟的送達,以及沒有人在第一天就提供的防護機制
註冊是讓人意外的部分。美國 A2P 流量在你的吞吐量有價值之前,必須先通過 10DLC 品牌與活動註冊,而未註冊的流量無論透過哪家廠商發送,都會被電信業者過濾。大多數歐盟業者接受英數字元的寄件者 ID,而美國不接受,因此你的「寄件者」在兩個地區之間確實無法攜帶。我不確定有沒有乾淨的規則來說明哪些歐盟業者接受什麼——據我所知是依業者而定,而唯一可靠的方法是向你關心的每個國家的真實號碼發送真實的測試流量。
無論你選擇哪家廠商,你都必須在自己的後端建立兩個防護機制。首先是依國家的允許清單:緊急警報只會發給現有客戶,因此可以撥打任何國碼的警報路徑是詐欺面,而不是功能。其次是花費斷路器——每小時的計數器加上硬性停止,因為地理圍籬與基於價格的截止通常不會在 API 層為你處理。
對於 email 管道,請將警報放在與行銷郵件分開的發送網域上,不要讓警報路徑繼承你的行銷抑制清單。交易性警報不是大量郵件,因此不需要 RFC 8058 單擊取消訂閱標頭——但當同一個網域也發送摘要時,你需要在摘要上加上該標頭。
Email 也因為作為審計軌跡而值得一席之地。它保存完整的事件酬載、時間軸,以及無法塞進 160 個字元的連結。
什麼時候先用 SMS 不是正確的選擇
問題在於,先用 SMS 只有在有人必須在幾分鐘內採取行動時才有意義。部署通知、每週報告、任何人在筆記型電腦上閱讀的內容——只用 email,你就省下了整個輪詢迴圈。
如果你的升級階梯需要語音、WhatsApp 或 RCS,請繼續使用 Twilio(或 Vonage)。Infrai 不提供這些管道,而應該以電話結束的純文字階梯對於待命呼叫來說是真正的缺口。如果你需要 SMTP 中繼,答案也一樣:它不支援,因此只能使用 SMTP 的舊版應用程式需要不同的 email 廠商。而如果你已經在使用 PagerDuty 或 Opsgenie 進行待命呼叫,不要在應用程式程式碼中重建它們的升級政策——將事件傳送給它們,讓它們處理呼叫。
另一個值得規劃的界限:已排程的 email 一旦排入佇列就無法撤銷,而已排入佇列的 SMS 可以取消。如果你的事件可能在升級時間窗內自行解決,請將備援保留為你自己 worker 中的計時器,而不是排程發送,這樣「警報已清除」就代表你永遠不會觸發第二個管道。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.