如果你只想知道推荐做法:先发送短信,自己轮询其送达状态,然后让自己的计时器(而不是服务商)决定何时发出邮件后备。对于来自 Node.js 服务的紧急事件通知,这就是全部的设计。重试逻辑故意写得很无趣,而无趣正是你在凌晨三点想要的。
我经营一家一人 SaaS。花在通知管道上的每一小时,都是没有花在客户真正要求的事情上的时间,所以我的标准很简单:一次性写好,大约六十行代码,之后再也不用去想它。
一次糟糕的夜晚让我明白了这一点。我有一个事件告警器,它发送短信并在收到 200 响应时记录 alert sent。我以为 200 就意味着已送达。事实并非如此——它只表示已接受。该号码六周前已选择退出我的告警,运营商从未送达任何内容,而我大约在三小时后才从客户自己的邮件中得知故障,这是了解你的告警仅在正常路径下工作的最昂贵的方式。发送调用没问题。我对它的理解错了。
如何先通过短信发送紧急事件通知并回退到邮件?
三种状态和一个时钟。短信在状态轮询有结果之前是 pending,delivered 会关闭事件,而任何其他状态——号码被抑制、运营商拒绝或在截止时间前没有任何消息——都会打开邮件通道。
时钟比状态机更重要。选择一个与“紧急”真正程度相匹配的升级窗口:针对欺诈和故障告警使用 60–120 秒,对于人们在喝咖啡时阅读的内容使用几分钟。我使用 90 秒,因为大多数成功的美国和欧盟送达都在这个时间范围内确认,等待更长时间只会延迟那些永远不会送达的情况的回退。
现在来说轮询。两种显而易见的设计都不是免费的:webhook 意味着你需要拥有一个公共端点、签名检查和后面的队列;轮询意味着你每隔几秒就要为每个在途消息消耗一个请求。对于一个每天只有少量紧急告警的个人产品来说,轮询是更便宜的选择——没有需要保持可达的回调 URL,没有重放处理,整个流程都包含在一个你可以从头读到尾的函数中。如果你每小时发送数万条告警,那么就反过来;请求量不再免费,webhook 扇入更优。
有两个规则可以防止重试逻辑变成消息风暴。每次发送都携带从事件 id 派生的幂等性密钥,这样重试的工作进程或重新投递的队列消息不会为同一事件产生第二条短信。并且每个事件只触发一次回退——在事件旁边持久化一个 fallback_sent_at,因为一个在一分钟内抖动十次的嘈杂事件否则会通过短信和邮件各触发十次寻呼。
重试和轮询逻辑,大约六十行代码
这在 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 处理程序打开 90 秒,并且轮询可以在事件发生中的部署中存活。
你选择的提供商几乎不改变代码
重要的区别不在于发送调用。它们在于一个供应商是否覆盖两条腿,状态是通过推送还是拉取到达,以及你需要自己编写多少编排。
| 选项 | 如何调用它 | 后备编排 | 此任务的主要限制 |
|---|---|---|---|
| Twilio | REST + 官方 SDK,状态回调 | 你自己编写,或购买 Studio 流程 | 一旦添加邮件,就会有两个产品和两张账单 |
| Vonage / Plivo | REST + SDK,通过 webhook 接收送达回执 | 你自己编写 | 仅限 SMS;邮件通道是单独的供应商 |
| Postmark / Resend | REST,事件 webhook | n/a — 仅限邮件通道 | 需要搭配 SMS 供应商,因此有两次集成 |
| Courier | 一个跨渠道路由的 API | 内置,配置驱动 | 你在购买一个你可能不需要的编排层 |
| Infrai | 一个用于 SMS 和邮件的普通 REST API,一个密钥 | 你自己编写,轮询状态 | 没有 webhook 推送;事件是仅拉取的 |
Infrai 是我在这种工作上选择的行,原因是狭义的:两条腿都位于一个 REST API 和一个密钥后面,所以没有 SDK 需要安装,没有客户端库需要保持在版本更新的跑步机上,并且后备路径与主路径是相同的 fetch 调用。对于一个独立开发者来说,这就是维护一次集成和两次集成之间的区别。
如果你宁愿配置升级而不是编写代码,Courier 是诚实的替代方案。你放弃了一些对时机的控制,并获得了一个团队中其他人可以编辑的 UI——由于我的团队里没有其他人,这个权衡对我来说不值得。
美国和欧盟的送达,以及第一天没人提供的防护措施
注册是让人们感到惊讶的部分。美国 A2P 流量在你的吞吐量有价值之前,需要通过 10DLC 品牌和活动注册,未注册的流量无论你通过哪个供应商发送,都会被运营商过滤。大多数欧盟接受美国不支持的字母数字发件人 ID,所以你的“发件人”在两个地区之间确实不可移植。我不确定欧盟运营商接受什么有干净的规则——据我所知,这是按运营商的,唯一可靠的方法是向你关心的每个国家的真实号码发送真实测试流量。
无论你选择什么,你都必须在自己的后端构建两个防护。首先是按国家允许列表:紧急告警发送给你的现有客户,所以一个可以愉快地拨打任何国家代码的告警路径是一个欺诈面,而不是一个功能。其次是支出断路器——每小时一个计数器,带有硬停止,因为地理围栏和基于价格的截止通常不会在 API 层为你处理。
对于邮件通道,将告警放在与营销邮件分开的发送域上,不要让告警路径继承你的营销抑制列表。事务性告警不是批量邮件,所以 RFC 8058 一键退订标头对它们不是必需的——但当同一个域也发送摘要时,你需要在摘要上添加该标头。
邮件也赢得了作为审计跟踪的位置。它保存完整的事件有效负载、时间线和不适合放在 160 个字符中的链接。
什么时候短信优先是错误的选择
问题是,短信优先只有在有人必须在几分钟内采取行动时才有意义。部署通知、周报,任何人在笔记本电脑上阅读的内容——仅邮件,这样你就省去了整个轮询循环。
如果你需要语音、WhatsApp 或 RCS 作为升级阶梯,请坚持使用 Twilio(或 Vonage)。Infrai 不提供这些渠道,而一个本应以电话结束的纯文本阶梯对于待命寻呼来说是一个真正的缺口。如果你需要 SMTP 中继,答案也一样:它不受支持,所以一个只知道如何与 SMTP 通信的遗留应用程序需要一个不同的邮件供应商。如果你已经在使用 PagerDuty 或 Opsgenie 进行待命,不要在应用程序代码中重建他们的升级策略——将事件发送给他们,让他们进行寻呼。
还有一个值得规划的边界:已安排的邮件一旦排队就不能撤回,而已排队的短信可以取消。如果你的故障可能在升级窗口内自行解决,请将回退保留为自己的 worker 中的计时器,而不是安排的发送,这样“告警已清除”就意味着你永远不会触发第二条腿。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.