メインコンテンツへスキップ
友田 陽大
Resend・メール配信
Resend
メール配信
冪等性
信頼性
TypeScript
テスト
可観測性

Resend の冪等キー・リトライ・エラー分類:1回の送信失敗でリードを落とさない本番実装

Resend の SDK は例外を投げず { data, error } を返します。失敗を retryable / sender_rejected / fatal の3分類に落とし、Idempotency-Key(1〜256文字・24時間)と指数バックオフ、タイムアウト、決定的テストまでを本番の実コードで解説します。

公開日
読了時間
25分
著者
友田 陽大
シェア

メール送信の実装は、たいてい次の数行から始まります。

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_rejectedpayload そのものが拒否された。ただし送信元を変えれば通る既知の正常な送信元へ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_field422invalid_idempotency_key400
invalid_idempotent_request409concurrent_idempotent_requests409
invalid_access422invalid_parameter422
invalid_region422rate_limit_exceeded429
missing_api_key401invalid_api_key403
suspended_api_key403invalid_from_address403
validation_error403not_found404
method_not_allowed405application_error500
internal_server_error500

新しめのバージョンでは、このエラー名の union にメンバーが増えます——restricted_api_key / invalid_attachment / daily_quota_exceeded / monthly_quota_exceeded / security_error です。ただし注意点が1つあります。上の対応表(定数)そのものは新しいバージョンの配布物から消えており、エラー関連の公開エクスポートは ErrorResponse 型だけです。したがって増えた名前のステータスは SDK ではなく公式 Errors ページが出どころで、restricted_api_key401invalid_attachment が 422、クォータ系が 429、security_error451 です。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 /emailsPOST /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_key400キーが1〜256文字の範囲外正しいキーで、あるいはキー無しで再試行する
invalid_idempotent_request409同じキーが別の payload で使われた再試行は無意味。キーか payload を変える。ほぼ自分側のバグ
concurrent_idempotent_requests409同じキーの元リクエストがまだ処理中後で再試行してよい(安全であると公式が明記)

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点だけ補足します。

  1. validation_errorsender_rejected に入れているのは、これが Resend で「from のドメインが未認証」を表すエラーだからです。ただし 403 の汎用バリデーションバケツでもあるため、無関係な原因でもスワップが1回発火し得ます。その代償はリクエスト1回で、そのあと元のエラーが表に出ます。リードを1件落とすより明らかに軽い。
  2. ステータス無し= retryable としてよいのは、届いていない以上まだ何も送られていないからです。届いたかもしれない失敗(タイムアウト)とは根拠が違います(後者は冪等キーが根拠。4.3 節)。
  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段階です。

  1. rate_limit_exceeded — 短い待機で回復します。バックオフでそのまま再送してよい領域です。公式の推奨アクションも「レスポンスヘッダを読んで頻度を下げる、キュー機構を導入する、同時リクエスト数を減らす」です。
  2. 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つあります。

  1. 同じ冪等キーで2回送る。 24時間以内なら Resend は2回目を実際には送らず、同じレスポンスを返します。同じメール ID が返れば、冪等キーが本当に効いていることの証明になります。副作用ゼロで、しかもリトライ安全性そのものを検証できます。
  2. 送信の手前で止まる経路を叩く。 このサイトの問い合わせ 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)の型定義・バンドル実装に基づき、実運用の判断軸を加えて再構成したものです。ステータスコードや上限値は更新され、公式ページ間で食い違う箇所も実在するため、本番採用時は各公式ページで最新値をご確認ください。

よくある質問

Resend の Node SDK は例外(throw)を投げますか?
API エラーもネットワーク障害も throw しません。すべて `{ data, error, headers }` の形で返ります。fetch 自体が失敗したときは `name: 'application_error'` かつ `statusCode: null` のエラーが入ります。ただし例外経路が2つだけあります。API キー未設定時のコンストラクタと、`@react-email/render` を入れずに `react:` オプションを使ったときのレンダリングです。したがって「error で分岐しつつ、外側に try/catch も置く」が正解です。
冪等キーにはどんな値を使うべきですか?
公式は UUID か、そのメールを一意に識別できる文字列を推奨し、`<event-type>/<entity-id>` という形式(例 `welcome-user/123456789`)を挙げています。長さは1〜256文字です。重要なのは「再試行のたびに変わらないこと」で、`Date.now()` や `Math.random()` を混ぜると別リクエスト扱いになり、重複送信の防波堤が消えます。Stripe の Webhook のように外部イベント起因なら、イベント ID から決定的に導出してください。
409 が返ってきたらリトライすべきですか?
409 は2種類あり、対応が正反対です。`invalid_idempotent_request` は「同じキーで別の payload を送った」で、公式は『キーか payload を変えない限り再試行は無意味』と明記しています。これは基本的に自分側のバグです。もう一方の `concurrent_idempotent_requests` は「同じキーの元リクエストがまだ処理中」で、公式は『あとで再試行しても安全』としています。前者は fatal、後者は retryable に分類してください。
429 が返ってきたら、どう待てばよいですか?
レスポンスヘッダを読んでください。`ratelimit-limit` / `ratelimit-remaining` / `ratelimit-reset`(リセットまでの秒数)/ `retry-after`(次のリクエストまで待つ秒数)が返ります。SDK v6 のレスポンスには `headers` が含まれるので、そこから直接読めます。なお 429 にはレート制限超過だけでなく `daily_quota_exceeded` / `monthly_quota_exceeded` も含まれ、こちらは数秒待っても回復しません。名前で見分けて挙動を変えるのが安全です。
from(送信元)が拒否されたとき、リトライに意味はありますか?
ありません。拒否されているのは payload そのものなので、同じ内容を何度送っても同じ結果です。ただし「送信元を既知の正常なアドレスに1回だけ差し替える」なら届きます。私自身、ドメイン認証レコードを apex に誤配置したせいで問い合わせ API が 502 を返し続けた障害を経験しており、その根治策の一部がこの一度きりの送信者スワップです。差し替え時は payload が変わるので、冪等キーも派生させる必要があります。
実際にメールを送らずにリトライ経路を検証するには?
3段構えが実用的です。(1) 単体テストでは送信関数・sleep・乱数を注入し、ネットワークも時計も使わずに全分岐を通す。(2) 結合テストでは `RESEND_BASE_URL` 環境変数でモックサーバへ向ける(SDK のバンドルに実装がありますが、公式ドキュメントには未記載です)。(3) 実際に送るなら `delivered@resend.dev` / `bounced@resend.dev` / `complained@resend.dev` / `suppressed@resend.dev` のテスト用アドレスを使う。ただしテスト送信も送信クォータを消費します。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

トランザクションメール基盤(Resend)の設計・実装・本番運用を承ります

「送ったつもりで届いていない」を、運用の注意深さではなくコードとDNSの構造で潰します。送信ドメインの認証設計(SPF/DKIM/DMARC)、Next.js からの送信経路とZodによる入力検証、冪等キーと指数バックオフ・送信者フォールバックによる回復性、Webhook でのバウンス/苦情の自動処理、One-Clickでの購読解除(RFC 8058)、そして PII を載せない構造化ログでの可観測性まで。このサイト自身の問い合わせ・資料請求・7通のメール講座・決済後の納品メールを Resend で本番運用し、認証レコードの誤配置で送信が全滅した実障害を根治した経験で伴走します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい