メール送信の実装は、たいてい次の数行から始まります。
const { data, error } = await resend.emails.send({ from, to, subject, html });
if (error) return NextResponse.json({ error: "送信に失敗しました" }, { status: 502 });
これは動きます。ただしプロバイダが一瞬でも不調になった瞬間、その送信は永久に失われます。訪問者には「送信に失敗しました」だけが表示され、あなたのメールボックスには何も届かず、再送のあてもありません。問い合わせフォームなら、それは1件のリードが消えたということです。
問題は「リトライしていない」ことだけではありません。失敗の種類を区別していないことです。ネットワークの瞬断は数百ミリ秒後に成功しますが、from ドメインが未認証なら1000回送っても同じ結果です。逆に、送信元さえ差し替えれば届く失敗もあります。この記事は Resend の実際のエラー契約から出発して、リトライ・冪等キー・エラー分類・タイムアウト・決定的テスト・可観測性までを、私がこのポートフォリオの本番で動かしている TypeScript のコードを教材に組み立てます。
全体像はResend 本番運用ガイドに、Next.js への組み込み方は App Router / Route Handler 実装ガイドにあります。本稿はその上で「落ちない送信」を作る回です。
1. 失敗を3つに分類する
回復性の設計は、リトライ回数を決めることから始まりません。「この失敗に対して、次に何をするのが正しいか」の種類を数えることから始まります。メール送信では、それはちょうど3種類あります。
| 分類 | 何が起きているか | 正しい次の一手 | 代表例 |
|---|---|---|---|
retryable | 一時的な障害。同じリクエストが少し後なら成功する | 指数バックオフで同じものを再送 | レート制限、プロバイダ 5xx、ソケット断、DNS 失敗、タイムアウト |
sender_rejected | payload そのものが拒否された。ただし送信元を変えれば通る | 既知の正常な送信元へ1回だけ差し替えて再送 | from のドメインが未認証 |
fatal | 設定・権限・入力の誤り。何をしても同じ結果 | 即座に諦めてログを残す | API キー不正、必須フィールド欠落、404 |
多くの実装は2分類(リトライするかしないか)で止まります。真ん中を独立させることに価値があるのは、それが「リトライは無意味だが、まだ救える」という唯一のケースだからです。ここを fatal に丸めると、ドメイン認証が壊れている間に届いたリードは全部消えます。逆に retryable に丸めると、絶対に成功しない送信を3回繰り返して同じだけ待たせるだけです。
私はこの真ん中のケースを、身をもって知りました。Resend のドメイン認証レコード(DKIM / SPF / MX / DMARC)をすべて apex に誤配置していたため、問い合わせ API がすべての送信で 502 を返し続けたことがあります。DNS を直すのが根治ですが、壊れている間に来た問い合わせを救えるのは、送信元を既知の正常なアドレスへ1回だけ差し替える設計だけでした。
設計上の含意:分類は「エラーの重さ」ではなく「次に取る行動」で切ってください。行動が同じなら同じ分類、違うなら分ける。この基準で切ると分類は3つに落ち着き、コードの分岐と1対1で対応します。
ドメイン認証そのものの直し方はドメイン認証・SPF/DKIM/DMARC と到達率のガイドにまとめました。本稿は「壊れている間も落ちない」側を扱います。
2. Resend のエラーの実体を正確に知る
2.1 SDK は例外を投げない
Resend の Node SDK(このリポジトリでは resend@6.4.1、npm の最新は 2026-08-06 時点で 6.18.1)の共通レスポンス型はこうです。
// data と error は排他。v6 では headers が付く(レート制限ヘッダが読める)
type Response<T> = ({ data: T; error: null } | { error: ErrorResponse; data: null })
& { headers: Record<string, string> | null };
type ErrorResponse = {
message: string;
statusCode: number | null; // ネットワーク層の失敗では null
name: RESEND_ERROR_CODE_KEY; // 文字列リテラルの union
};
つまり resend.emails.send() は API エラーでもネットワーク障害でも throw しません。私が SDK のバンドル(v6.18.1)を読んだ限り、fetch 自体が失敗したケースは { data: null, headers: null, error: { name: "application_error", statusCode: null, message: "Unable to fetch data. The request could not be resolved." } } が返ります。公式ドキュメントも「resend.emails.send() に try/catch を使うな、SDK は throw ではなく { data, error } を返す」と明記しています。
ただしこれはHTTP/ネットワーク層に限った話で、例外経路は2つ実在します。
| 例外を投げる箇所 | 条件 | 実務上の意味 |
|---|---|---|
new Resend(key) | キー未指定かつ RESEND_API_KEY も無い | モジュールスコープで生成すると import 時点で落ちる。graceful な 500 すら返せない |
react: のレンダリング | @react-email/render が未インストール(v5.0.0 以降オプショナルな peer dependency) | { error } ではなく素の Error が飛ぶ |
したがって正解は「error で分岐し、さらに外側に try/catch も置く」です。前者はクライアントの遅延生成で避けられます。
// モジュールスコープで new Resend(...) しない(import 時 throw の回避)
let resendClient: Resend | null = null;
function getResend(): Resend {
if (!resendClient) {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) throw new Error("RESEND_API_KEY is not configured");
resendClient = new Resend(apiKey);
}
return resendClient;
}
なお SDK は開発環境ではエラーを console.error にも出します(NODE_ENV が production のときだけ抑制されます)。ローカルで見えていたその出力は本番には無いので、次章以降のログは自分で書く必要があります。
2.2 エラー名の一覧(SDK の定数)
SDK の型定義には、エラー名 → HTTP ステータスの対応表が入っています。v6.4.1 の値はこうです。
| エラー名 | ステータス | エラー名 | ステータス |
|---|---|---|---|
missing_required_field | 422 | invalid_idempotency_key | 400 |
invalid_idempotent_request | 409 | concurrent_idempotent_requests | 409 |
invalid_access | 422 | invalid_parameter | 422 |
invalid_region | 422 | rate_limit_exceeded | 429 |
missing_api_key | 401 | invalid_api_key | 403 |
suspended_api_key | 403 | invalid_from_address | 403 |
validation_error | 403 | not_found | 404 |
method_not_allowed | 405 | application_error | 500 |
internal_server_error | 500 |
新しめのバージョンでは、このエラー名の union にメンバーが増えます——restricted_api_key / invalid_attachment / daily_quota_exceeded / monthly_quota_exceeded / security_error です。ただし注意点が1つあります。上の対応表(定数)そのものは新しいバージョンの配布物から消えており、エラー関連の公開エクスポートは ErrorResponse 型だけです。したがって増えた名前のステータスは SDK ではなく公式 Errors ページが出どころで、restricted_api_key が 401、invalid_attachment が 422、クォータ系が 429、security_error が 451 です。451 と、権限不足が 403 ではなく 401 で来ることは、記憶で書くと確実に外すポイントです。
2.3 なぜ「名前で分類し、ステータスは補助」なのか
ここが本章の要点です。公式ドキュメントの Errors ページと SDK の定数は、同じエラーに別のステータスを割り当てている箇所があります。
invalid_from_address— 公式 Errors ページの表では 422、SDK の定数では 403。validation_error— Errors ページ自身が 400 と 403 の両方に載せており、ページネーションのエラー例では"statusCode": 422が示されている。
どちらが「正しい」かを私は断定しません(実際に返る値は環境と時期で変わり得ます)。断定する必要もありません。分類の第一キーをエラー名にしておけば、ステータスがどちらであっても分類は変わらないからです。ステータスは「知らない名前が来たとき」のフォールバックとしてだけ使う——この一手で、ドキュメントの揺れに実装が引きずられなくなります。
3. 冪等キーの正確な仕様
リトライを入れる前に、重複送信の防波堤を先に作ります。順序が逆だと、リトライを足した日に同じメールが2通届きます。
| 項目 | 仕様 |
|---|---|
| HTTP ヘッダ名 | Idempotency-Key |
| SMTP の場合 | メールヘッダ Resend-Idempotency-Key |
| 長さ | 1〜256文字 |
| 保持期間 | 24時間 |
| 対応エンドポイント | POST /emails と POST /emails/batch のみ |
| 推奨フォーマット | UUID、または <event-type>/<entity-id>(例 welcome-user/123456789) |
| バッチの推奨キー | バッチ全体を表すキー(例 team-quota/123456789) |
挙動は明快です。同じ冪等キーの送信が24時間以内に既にあれば、Resend は実際には送信せず、同じレスポンスを返します。 だからこそ「同じキーでの再送は安全」なのであって、キーを付けないリトライはただの二重送信リスクです。
3.1 キーは第2引数に渡す(payload に入れても効かない)
await resend.emails.send(
{ from: "Acme <onboarding@resend.dev>", to: ["delivered@resend.dev"],
subject: "hello world", html: "<p>it works!</p>" },
// ここに置いたときだけ Idempotency-Key ヘッダとして送信される
{ idempotencyKey: "welcome-user/123456789" },
);
注意が必要なのは、公式ドキュメントの一部(AI 向けのプロンプトブロック)が idempotencyKey を payload の中に書いていることです。その形は CreateEmailOptions の型に存在しないので型エラーになり、仮に型を迂回しても SDK はそのフィールドを読まずに捨てます。エラーも警告も出ないまま、冪等性だけが無効になるという最悪の壊れ方をします。生成 AI に書かせたコードをレビューするときは、まずここを見てください。
同様に、payload の headers に独自の識別子(X-Entity-Ref-ID など)を入れても重複排除はされません。Resend が重複排除を保証しているのは Idempotency-Key だけです。
3.2 409 は2種類ある。意味も対処も正反対
| 名前 | ステータス | 意味 | すべきこと |
|---|---|---|---|
invalid_idempotency_key | 400 | キーが1〜256文字の範囲外 | 正しいキーで、あるいはキー無しで再試行する |
invalid_idempotent_request | 409 | 同じキーが別の payload で使われた | 再試行は無意味。キーか payload を変える。ほぼ自分側のバグ |
concurrent_idempotent_requests | 409 | 同じキーの元リクエストがまだ処理中 | 後で再試行してよい(安全であると公式が明記) |
invalid_idempotent_request が出たら、真っ先に疑うべきはpayload に毎回変わる値が混ざっていないかです。本文に生成時刻を埋め込む、Math.random() で挨拶文を選ぶ、といった実装は、リトライのたびに「同じキーで別 payload」を作ります。concurrent_idempotent_requests は逆に、2つのワーカーが同じイベントを同時に処理したときの正常な合図です。だからこれは retryable に分類し、少し待って再送すれば元リクエストの結果がそのまま返ります。
決済の冪等性と考え方は同じで、Stripe 本番運用ガイドで書いた「イベント ID から決定的にキーを導出する」がそのまま使えます。このサイトの Stripe Webhook も同じ形です。
// Stripe が同じイベントを再送しても Resend 側で重複排除される。
// 条件は「キーがイベント ID から決定的に決まること」だけ。
const { error } = await client.emails.send(
{ from, to: [purchase.email], subject, html, text },
{ idempotencyKey: `stripe-fulfill:${eventId}` },
);
4. 実装:I/O を注入した純粋関数にする
ここからは、このサイトの lib/email-delivery.ts を教材に設計を追います。このモジュールはプロバイダ非依存かつ I/O 非依存で、送信関数・sleep・乱数をすべて引数で受け取ります。
4.1 分類器
/** 表に無い名前は fatal に倒す(未知の恒久エラーをループで叩き続けない) */
const RESEND_ERROR_KINDS: Readonly<Record<string, DeliveryErrorKind>> = Object.freeze({
rate_limit_exceeded: "retryable",
application_error: "retryable",
internal_server_error: "retryable",
concurrent_idempotent_requests: "retryable",
invalid_from_address: "sender_rejected",
validation_error: "sender_rejected",
});
export function classifyDeliveryError(error: DeliveryError | null | undefined): DeliveryErrorKind {
// Object.hasOwn を使うのは、`constructor` や `toString` という名前のエラーが
// プロトタイプチェーンを辿って「真だが分類値ではない何か」を返すのを防ぐため
const byName =
error?.name && Object.hasOwn(RESEND_ERROR_KINDS, error.name)
? RESEND_ERROR_KINDS[error.name]
: undefined;
if (byName) return byName;
const status = error?.statusCode;
// ステータス無し=プロバイダに届いていない(DNS 失敗・ソケット断・中断)。
// 何も送られていないので、再送が二重送信になることはない
if (status == null) return "retryable";
if (status === 429 || status >= 500) return "retryable";
return "fatal";
}
3点だけ補足します。
validation_errorをsender_rejectedに入れているのは、これが Resend で「fromのドメインが未認証」を表すエラーだからです。ただし 403 の汎用バリデーションバケツでもあるため、無関係な原因でもスワップが1回発火し得ます。その代償はリクエスト1回で、そのあと元のエラーが表に出ます。リードを1件落とすより明らかに軽い。- ステータス無し= retryable としてよいのは、届いていない以上まだ何も送られていないからです。届いたかもしれない失敗(タイムアウト)とは根拠が違います(後者は冪等キーが根拠。4.3 節)。
- この表の弱点も書いておきます。 クォータ枯渇(
daily_quota_exceeded/monthly_quota_exceeded)は 429 なので、名前が表に無いとステータス経由でretryableに落ちます。しかし数百ミリ秒待っても回復しません。上限3回・指数バックオフなら被害は「無駄な2回」で済みますが、これらを明示的にfatalとして登録するほうが正直な設計です。
4.2 指数バックオフ+ジッター、そして派生キー
/** 決定的な指数の「床」+最大50%のランダムな上乗せ */
export function backoffDelayMs(attempt: number, baseDelayMs: number, jitter: number): number {
const exponential = baseDelayMs * 2 ** (attempt - 1);
return Math.round(exponential * (1 + jitter * 0.5));
}
床があるからテストで待ち時間を固定でき、ジッターがあるから同時に失敗した送信群がロックステップで再試行しません。プロバイダの不調は複数のリクエストに同時に当たるので、全員が同じ式で待つと、復旧しかけたプロバイダを一斉再送で押し戻すだけになります。乱数は引数なので、テストでは random: () => 0 を渡して待ち時間を厳密に検証できます。
const FALLBACK_KEY_SUFFIX = ".fb";
export const MAX_IDEMPOTENCY_KEY_LENGTH = 256;
// フォールバックは payload(from)が変わるので、同じキーでは 409
// invalid_idempotent_request になる。だから接尾辞で派生させる。先に切り詰めるのは、
// 呼び出し側のキーが既に上限ちょうどだと追記で 256 文字を超えてしまうため
function fallbackKey(idempotencyKey: string): string {
const room = MAX_IDEMPOTENCY_KEY_LENGTH - FALLBACK_KEY_SUFFIX.length;
return `${idempotencyKey.slice(0, room)}${FALLBACK_KEY_SUFFIX}`;
}
「切り詰めてから足す」のは細部に見えて、回復性コードが自分の存在理由を裏切らないための1行です。上限ぎりぎりのキーを渡された瞬間だけフォールバックが 400 で死ぬ不具合は、まさに障害時にしか再現しません。なおスワップ時は待ちません。拒否されたのは payload であって混雑ではないので、待つ理由がないからです。
4.3 タイムアウト
タイムアウトは「あれば嬉しい」機能ではありません。ハングしたプロバイダはリトライロジックに到達しないからです。await send(...) で止まったまま、プラットフォームが関数を殺すのを待つだけになり、そのとき失敗ログも一緒に消えます。
// 超過時に付ける合成エラー名。ステータスを持たないので retryable と判定される。
// それが正しいのは再試行が同じ冪等キーを使うから——タイムアウトした試行が実は
// 届いていたとしても、プロバイダ側の重複排除が再送を吸収する
export const TIMEOUT_ERROR_NAME = "request_timeout";
export const maxDuration = 25; // リトライは複数往復+バックオフを費やす
const SEND_TIMEOUT_MS = 8_000; // 1回あたりの上限はその内側に収める
関数の実行モデルとタイムアウトの考え方はVercel Functions・Fluid Compute ガイドにまとめています。
4.4 ループ本体:終了性を構造で保証する
for (let attempt = 1; ; attempt++) {
senderAttempt++;
// 同じ送信者の再試行はキーを「そのまま」使う=プロバイダが重複排除する
const key = usedFallbackSender ? fallbackKey(idempotencyKey) : idempotencyKey;
// …送信・タイムアウト・throw の正規化…
if (!response.error) return { ok: true, messageId: response.data?.id ?? null };
const kind = classifyDeliveryError(response.error);
// 送信者スワップは一度きり。payload が拒否された以上、待つ理由はない
if (kind === "sender_rejected" && !usedFallbackSender &&
Boolean(fallbackFrom) && fallbackFrom !== sender) {
sender = fallbackFrom as string;
usedFallbackSender = true;
senderAttempt = 0; // 新しい送信者にはフレッシュな予算を与える
continue;
}
if (kind === "retryable" && senderAttempt < maxAttempts) {
await sleep(backoffDelayMs(senderAttempt, baseDelayMs, random()));
continue;
}
return { ok: false, kind /* …試行の履歴… */ };
}
無限ループに見えますが、終了性は構造で保証されています。継続するのは「maxAttempts 未満の retryable」か「一度きりの送信者スワップ」だけなので、プロバイダが何を返しても最大 2 * maxAttempts 回で必ず抜けます。回復性コードでいちばん怖いのは、障害時にだけ回り続けるループです。回数ではなく構造で上界を作ると、その恐怖が消えます。
senderAttempt をスワップ時にリセットしている理由も同じ筋です。壊れた送信者で使い切った試行回数は、正常な送信者について何も語りません。ここを引き継ぐと、救うために用意した経路が最初の一時的な失敗で終わります。
呼び出し側は、送信そのものをクロージャとして渡すだけです。
result = await deliverEmail({
from: configuredFrom,
// 同じアドレスへのスワップは同じ失敗を繰り返すだけなので undefined にする
fallbackFrom: configuredFrom === DEFAULT_FROM ? undefined : DEFAULT_FROM,
idempotencyKey: safeIdempotencyKey(request.headers.get("Idempotency-Key")),
timeoutMs: SEND_TIMEOUT_MS,
send: (from, idempotencyKey) =>
client.emails.send(
{ from, to: [recipient], replyTo: data.email, subject, html, text },
// 重複排除はこのオプションだけが行う。無ければ上のリトライが二重送信になる
{ idempotencyKey },
),
});
重要な適用条件:フォールバック送信者に使える
onboarding@resend.devは、アカウント所有者自身のアドレスにしか配信できません。つまりこのスワップが有効なのは「自分宛の通知メール」だけです。訪問者や顧客に送るメールで同じことをしてもスワップ先で拒否されるので、fallbackFromは渡さないでください。私のリポジトリでも、問い合わせ通知(自分宛)にだけこの経路を有効にしています。
5. レート制限との付き合い方
Resend のレート制限は チームあたり 10 リクエスト/秒 が既定です。重要なのは適用範囲で、API キー単位でもドメイン単位でもなくチーム単位です。公式の例示どおり、サービス A が6リクエスト、サービス B が4リクエストを同じ秒に投げれば、その時点で上限に達します。またバースト許容は無いと明記されており、1秒あたり10なら同じ秒の11本目は 429 です(現在値は https://resend.com/settings/usage で確認でき、信頼された送信者は申請で引き上げ可能)。
レスポンスヘッダは IETF のドラフト(draft-06)に準拠しています。
| ヘッダ | 意味 |
|---|---|
ratelimit-limit | ウィンドウ内で許可される最大リクエスト数 |
ratelimit-remaining | 現在のウィンドウで残っているリクエスト数 |
ratelimit-reset | 制限がリセットされるまでの秒数 |
retry-after | 次のリクエストまで待つべき秒数 |
消費枠そのものは x-resend-daily-quota(無料プランにのみ返る)と x-resend-monthly-quota から読めます。ヘッダ一覧とコストの読み方はResend 本番運用ガイドにあるので、ここでは待ち時間の判断に使う4つだけを扱います。
SDK v6 のレスポンスには headers が含まれるので、これらは戻り値から直接読めます。
const { data, error, headers } = await resend.emails.send(payload, { idempotencyKey });
// 残量が枯渇しかけているなら、次のバッチ投入を遅らせる判断に使える
const remaining = Number(headers?.["ratelimit-remaining"] ?? Number.NaN);
ただし正直に書くと、この読み方を示した公式ページを私は見つけられませんでした。型(Response<T> の headers)と SDK 実装からの推論です。仕様として保証されたものではなく SDK の内部実装に依存する使い方だと理解した上で採用してください。
429 が来たときの判断は2段階です。
rate_limit_exceeded— 短い待機で回復します。バックオフでそのまま再送してよい領域です。公式の推奨アクションも「レスポンスヘッダを読んで頻度を下げる、キュー機構を導入する、同時リクエスト数を減らす」です。daily_quota_exceeded/monthly_quota_exceeded— 待っても数時間〜翌月まで回復しません。リトライではなく、送信停止・キュー保留・アラートの領域です。
構造的な逃げ道も1つあります。**バッチ送信はレート制限のカウント上「1リクエスト」**です。1通ずつ送るループを POST /emails/batch に畳むだけで、レート制限あたりのスループットが変わります。詳細は一括送信/予約/Broadcasts ガイドへ。なお、Resend の 429 を受けてから慌てるより自分のエンドポイントで先に絞るほうが安く、その設計はサーバーレスのレート制限ガイドにまとめています。
6. テスト:時計とネットワークを使わずに全分岐を通す
リトライのテストは「実際に待つ」と壊れます。3秒待つテストは遅く、しかも不安定です。sleep と乱数を注入すると、この問題は消えます。
it("retries a transient failure with exponential backoff and succeeds", async () => {
// 待たないダミー時計。「本来どれだけ寝たか」だけを slept に記録する
const slept: number[] = [];
const sleep = async (ms: number) => { slept.push(ms); };
const send = vi.fn<(from: string, key: string) => Promise<ProviderResponse>>()
.mockResolvedValueOnce(fail("rate_limit_exceeded", 429))
.mockResolvedValueOnce(fail("internal_server_error", 500))
.mockResolvedValueOnce(ok("msg_2"));
const result = await deliverEmail({
send, from: "a@x.test", idempotencyKey: "k",
sleep, random: () => 0, baseDelayMs: 100,
});
expect(result.ok).toBe(true);
expect(slept).toEqual([100, 200]);
// 同じ送信者の再試行はキーを使い回す=プロバイダ側で重複排除される
expect(send.mock.calls.map(([, key]) => key)).toEqual(["k", "k", "k"]);
});
固定すべき不変条件は「成功したか」ではなく、回復性の約束そのものです。私が実際に固定しているのは、同じ送信者での再試行がキーを変えないこと、スワップ時のキーが**.fb 付きの派生キーになること、上限ちょうどのキーでも派生キーが256文字を超えないこと、sender_rejected では待たないこと(slept が空配列)、fatal では1回で止まること、ハングした送信がタイムアウトで retryable になり同じキーで再試行されること、そして失敗時も試行の履歴が残る**こと(インシデントログの中身そのもの)です。
6.1 実際に送らずに結合を確かめる
- モックサーバへ向ける:SDK は
RESEND_BASE_URL環境変数(およびRESEND_USER_AGENT)でベース URL を上書きできます。ローカルの HTTP サーバに向ければ、429 も 5xx も「無応答(ハング)」も意図的に再現できます。ただしこれらの環境変数は公式ドキュメントに記載がなく、SDK のバンドルから確認した挙動です。バージョン間で変わり得る前提で使ってください(新しめのバージョンではコンストラクタのbaseUrl/userAgentオプションでも同じことができます)。 - テスト用アドレスを使う:
delivered@resend.devは配信成功、bounced@resend.devはバウンス(SMTP 550 5.1.1 が返る)、complained@resend.devは苦情、suppressed@resend.devは抑制を再現します。プラスラベル(delivered+signup@resend.devなど)も使えます(suppressedはラベル未対応)。テスト送信も送信クォータを消費する点と、@example.com/@test.com宛はブロックされて 422 になる点に注意してください。バウンス・苦情の受け止め方はWebhook 署名検証とバウンス/苦情処理のガイドにあります。
6.2 本番での無害な検証
本番で確かめたいのは、たいてい「経路が生きているか」であって「メールが届くか」ではありません。無害な手が2つあります。
- 同じ冪等キーで2回送る。 24時間以内なら Resend は2回目を実際には送らず、同じレスポンスを返します。同じメール ID が返れば、冪等キーが本当に効いていることの証明になります。副作用ゼロで、しかもリトライ安全性そのものを検証できます。
- 送信の手前で止まる経路を叩く。 このサイトの問い合わせ API は、スパム判定(滞在時間が閾値未満、またはハニーポットに入力あり)に該当したリクエストにも 200 を返して送信せずに終わります。つまり
elapsedMsを 0 にしたリクエストを投げれば、レート制限・JSON パース・スキーマ検証まで本番で通しつつ、メールは1通も出ません。自分のハンドラにも、こうした「送信直前で止まる正当な経路」があるか確認してみてください。
7. 可観測性:フォールバック成功こそ warn で残す
リトライを入れると、成功しているのに壊れている状態が生まれます。送信者が拒否されているのにフォールバックで届いている、という状態です。訪問者には何も見えず、メールも届くので誰も気づきません。そしてフォールバックも死んだ日に、初めて全損として顕在化します。
// フォールバックで成功した=設定した送信者は壊れているがリードは救えた。
// 訪問者にもメール本文にも痕跡が残らないので、ここで声を上げるしかない
log(result.usedFallbackSender ? "warn" : "info", "sent", {
attempts: result.attempts.length,
usedFallbackSender: result.usedFallbackSender,
});
log("error", "delivery_failed", {
kind: result.kind, errorName: result.errorName,
statusCode: result.statusCode, attempts: result.attempts.length,
});
ログの作法は3つあります。最初の2つ——1行の JSON にする(複数引数の console.* はホスティング側のログビューアで結合・省略され得る)と、PII を載せない(記録するのは分類・エラー名・ステータス・試行回数だけ。試行の履歴 attempts は設計上 PII を含みません)——は route handler 全体の作法なので、log() の実装ごと App Router / Route Handler 実装ガイド に譲ります。3つ目がリトライ固有の要件です。
失敗ログは分類ごとに読めるようにする。 kind / errorName / statusCode / attempts が揃っていれば、「今日の 502 は3回リトライして諦めた rate_limit_exceeded なのか、1回で終わった invalid_api_key なのか」がログだけで分かります。逆にこの4つのどれかを落とすと、障害のたびに「何回試したのか」をコードから推測する羽目になります。
最後に運用上の注意を1つ。本番ビルドで console 出力を削る最適化を有効にしているなら、この行が本当に本番に残るかを一度確かめてください(Next.js の removeConsole での具体的な絞り方は上のガイドにあります)。障害の最中に「ログが出ていない」と気づくのは、いちばん高くつく確認の仕方です。
8. 本番チェックリスト
-
{ data, error }で分岐し、それでも外側に try/catch がある(コンストラクタとreact:の例外経路) -
new Resend(...)をモジュールスコープでやっていない - 失敗を retryable / sender_rejected / fatal に分類し、第一キーがエラー名になっている
- 表に無い名前が fatal に倒れる(未知の恒久エラーをループで叩かない)
- リトライは指数バックオフ+ジッターで、試行回数の上限が構造で保証されている
- すべての送信に
idempotencyKeyを第2引数で渡している(payload の中ではない) - 冪等キーが再試行のたびに変わらない(時刻・乱数を混ぜていない)
- 送信者スワップ時は派生キーを使い、256文字の上限を超えない
-
concurrent_idempotent_requestsは retryable、invalid_idempotent_requestは fatal - クォータ系 429 と
rate_limit_exceededを区別している - 1回の送信にタイムアウトがあり、関数全体の実行時間予算の内側に収まっている
- ログが1行 JSONで、PII を含まず、フォールバック成功が warn で残る
- sleep と乱数を注入した決定的なテストで全分岐を通している
- 顧客宛メールに
onboarding@resend.devへのフォールバックを使っていない
まとめ
回復性は、リトライ回数を増やすことではありません。「この失敗に対して次に何をすべきか」を型と分岐で表現しきることです。Resend の場合、そのための材料は全部揃っています。SDK が throw せず { data, error } を返すこと、エラー名が文字列リテラルの union であること、Idempotency-Key が24時間・1〜256文字で同じレスポンスを返すこと。あとは「同じ payload を再送しても無駄だが送信元を変えれば届く」という真ん中のケースを潰さずに残すだけです。
私はこの設計を、ドメイン認証を壊した状態で問い合わせがすべて 502 になるという自分の障害を通じて手に入れました。同じ授業料を払う必要はありません。今日できることは1つです——あなたの resend.emails.send() に、第2引数の idempotencyKey が付いているか確認してください。付いていないなら、リトライを足す前にそこから始めてください。
Resend を選ぶかどうかの判断自体は別問題です。他サービスとの比較はメール配信サービス選定ガイドにまとめました。
この記事は Resend 公式ドキュメント(Idempotency Keys / Errors / Rate Limit / API Reference Introduction / Send test emails、2026年8月時点)と、実際にインストールされている Node SDK(
resend@6.4.1、および npm 最新の 6.18.1)の型定義・バンドル実装に基づき、実運用の判断軸を加えて再構成したものです。ステータスコードや上限値は更新され、公式ページ間で食い違う箇所も実在するため、本番採用時は各公式ページで最新値をご確認ください。