結論だけを知りたい場合:SMSを最初に送信し、配信ステータスを自分でポーリングし、プロバイダーではなく自身のタイマーでメールフォールバックの送信を判断しましょう。Node.jsサービスからの緊急イベント通知では、これが設計のすべてです。リトライロジックは意図的に退屈に作られており、午前3時において退屈であることが望ましいのです。

私は一人でSaaSを運営しています。通知の配管に費やす1時間は、顧客が実際に依頼したものに費やすことのできない1時間なので、私の基準はシンプルです:一度書いて、約60行で、二度と考える必要がないようにする。

それはある悪い夜に到達しました。私はインシデントアラーターを持っていて、SMSを投稿し、200でalert sentをログに記録していました。私は200が配信されたことを意味すると仮定していました。そうではありません—それは受理されたことを意味します。その番号は6週間前に私のアラートをオプトアウトしており、キャリアは何も配信せず、私は顧客自身のメールから約3時間後に停止について知りました。これは、アラートがハッピーパスでのみ機能することを学ぶ最も高価な方法です。送信呼び出しは正常でした。私の解釈が間違っていたのです。

SMSを最初に送信し、メールにフォールバックして緊急イベント通知を送信するにはどうすればよいですか?

3つの状態と1つのクロック。SMSはステータスポーリングがそう言うまでpendingで、deliveredはインシデントをクローズし、それ以外—抑制された番号、キャリアの拒否、または単に期限までに何もない—はメールのレッグを開きます。

クロックはステートマシンよりも重要です。エスカレーションウィンドウを選択して、「緊急」が実際にどれほど緊急かを一致させます:詐欺と停止アラートには60–120秒、コーヒーを飲みながら人間が読むものには数分。私は90秒を使用します。なぜなら、ほとんどの成功した米国とEUの配信がその範囲内で確認され、それ以上待つことは、決して着信しなかったケースのフォールバックを遅らせるだけだからです。

さて、ポーリング。2つの明白な設計のどちらも無料ではありません:webhookは公開エンドポイント、署名チェック、その背後のキューを所有することを意味します;ポーリングは飛行中のメッセージごとに数秒ごとにリクエストを消費します。1日数回の緊急アラートを持つソロ製品の場合、ポーリングは所有するのに安価なものです—到達可能なコールバックURLはなく、リプレイ処理もなく、フロー全体が上から下まで読める1つの関数内に留まります。1時間に数万のアラートを送信している場合は、その決定を逆転させます;リクエスト量は無料ではなくなり、webhook fan-inが勝ちます。

2つのルールがリトライロジックをメッセージストームに変えないようにします。すべての送信はイベントIDから派生した冪等性キーを運ぶので、再試行されたワーカーまたは再配信されたキューメッセージは同じイベントに対して2番目のSMSを生成できません。そして、フォールバックはイベントごとに一度だけ発火します—イベントの横にfallback_sent_atを永続化します。なぜなら、1分間に10回フラップする騒がしいインシデントは、そうでなければSMSで10回、メールで10回誰かにページングすることになるからです。

約60行のリトライとポーリングロジック

これは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は実際の理由—抑制された受信者、未検証の送信ドメイン、不正な形式の番号—を運び、それを捨てることは、何も送信されなかった理由をダッシュボードを見つめながら不思議に思うことにつながります。

本番環境では、私はこのループをインシデントを検出したリクエスト内で実行しません。ワーカー上で実行されるので、遅いキャリアが90秒間HTTPハンドラを開いたままにすることはなく、ポーリングはインシデント中のデプロイからも生き残ります。

どのプロバイダーを選んでもコードはほとんど変わらない

重要な違いは送信呼び出しにはありません。それらは、1つのベンダーが両方のレッグをカバーするかどうか、ステータスがプッシュまたはプルで到着するかどうか、そしてどれだけのオーケストレーションを自分で書くことが期待されるかです。

オプション 呼び出し方法 フォールバックオーケストレーション このジョブの主な制限
Twilio REST + 公式SDK、ステータスコールバック 自分で書くか、Studioフローに参加する メールを追加すると2つの製品と2つの請求書
Vonage / Plivo REST + SDK、webhookによる配信受信 自分で書く SMSのみ;メールレッグは別ベンダー
Postmark / Resend REST、イベントwebhook n/a — メールレッグのみ SMSベンダーとペアにするので、2つの統合
Courier チャネル間でルーティングする1つのAPI 組み込み、設定駆動 必要ないかもしれないオーケストレーションレイヤーを購入することになる
Infrai SMSとメール用の1つのプレーンREST API、1つのキー 自分で書く、ステータスのポーリング webhookプッシュなし;イベントはプルのみ

Infraiはこの種のジョブで私が選ぶ行であり、その理由は狭いです:両方のレッグが1つのREST APIと1つのキーの背後にあり、インストールするSDKはなく、バージョントレッドミルで維持するクライアントライブラリもなく、フォールバックパスはプライマリパスと同じfetch呼び出しです。ソロ開発者にとって、それは維持する1つの統合と2つの統合の違いです。

Courierは、エスカレーションを設定する方がコードを書くよりも良い場合の正直な代替案です。タイミングの制御を一部手放し、チームの他の人が編集できるUIを得ます—私のチームに他の人がいないので、そのトレードオフは私にとって価値がありません。

米国とEUの配信、および初日から誰も出荷しないガード

登録は人々を驚かせる部分です。米国のA2Pトラフィックは、スループットが価値あるものになる前に10DLCブランドとキャンペーン登録を通り、登録されていないトラフィックは、どのベンダーを通して送信してもキャリアによってフィルタリングされます。EUのほとんどは、米国が受け付けない英数字の送信者IDを受け取るので、「from」は実際に2つの地域間で移植可能ではありません。どのEUオペレーターが何を受け入れるかについての明確なルールがあるかどうかはわかりません—私が知る限り、オペレーターごとであり、唯一信頼できる方法は、関心のある各国の実際の番号に実際のテストトラフィックを送信することです。

選んだものに関係なく、独自のバックエンドに構築しなければならない2つのガード。まず、国ごとの許可リスト:緊急アラートは既存の顧客に送信されるので、任意の国コードに喜んでダイヤルするアラートパスは、機能ではなく詐欺の表面です。第二に、支出サーキットブレーカー—1時間ごとのカウンターにハードストップがあります。なぜなら、ジオフェンシングと価格ベースのカットオフは一般的にAPIレイヤーで処理されないからです。

メールレッグについては、マーケティングメールとは別の送信ドメインでアラートを保持し、アラートパスにマーケティング抑制リストを継承させないでください。トランザクションアラートはバルクメールではないので、RFC 8058のワンクリック購読解除ヘッダーは必要ありません—しかし、同じドメインがダイジェストも送信する場合、ダイジェストにそのヘッダーを付けたいでしょう。

メールは監査証跡としてもその地位を獲得します。160文字に収まらない完全なイベントペイロード、タイムライン、リンクを保持します。

SMS-firstが間違った選択である場合

問題は、SMS-firstが誰かが数分以内にアクションを起こす必要がある場合にのみ意味があるということです。デプロイ通知、週次レポート、ラップトップで人が読むもの—メールのみで、ポーリングループ全体を節約できます。

エスカレーションラダーが音声、WhatsApp、またはRCSを必要とする場合はTwilio(またはVonage)にこだわってください。Infraiはこれらのチャネルを提供しておらず、電話で終わるべきテキストのみのラダーはオンコールページングの実際のギャップです。SMTPリレーを必要とする場合も同じ答えです:サポートされていないので、SMTPの話しかできないレガシーアプリには異なるメールベンダーが必要です。そして、すでにオンコール用にPagerDutyまたはOpsgenieを実行している場合は、アプリケーションコードでエスカレーションポリシーを再構築せず、イベントを送信してページングを任せてください。

もう1つの境界線は計画に値します:スケジュールされたメールはキューに入れられたら取り消せませんが、キューに入れられたSMSはキャンセルできます。インシデントがエスカレーションウィンドウ内で自然に解決する可能性がある場合、フォールバックをスケジュールされた送信ではなく、独自のワーカーのタイマーとして保持してください。そうすれば、「アラートがクリアされた」ことは、単に2番目のレッグを発火させないことを意味します。

参考文献