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

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

- 公開日: 2026-08-06
- 著者: 友田 陽大
- タグ: Resend, メール配信, 冪等性, 信頼性, TypeScript, テスト, 可観測性
- URL: https://tomodahinata.com/blog/resend-idempotency-retry-error-handling-reliability-guide
- カテゴリ: Resend・メール配信
- 総合ガイド: https://tomodahinata.com/blog/resend-transactional-email-production-guide

## 要点

- Resend の Node SDK は API 失敗でもネットワーク失敗でも例外を投げず、`{ data, error, headers }` を返す。try/catch だけの実装は失敗を素通りさせるので、分岐は必ず `error` で行い、例外はコンストラクタと React レンダリングの2経路にだけ備える
- 失敗は retryable / sender_rejected / fatal の3つに分類する。『同じ payload を再送しても無駄だが、送信元を差し替えれば届く』という真ん中のケースを独立させられるかどうかが、リードを落とすかどうかの分岐点になる
- 冪等キーは `Idempotency-Key` ヘッダ、1〜256文字、保持24時間、対応は POST /emails と /emails/batch のみ。409 は2種類あり、invalid_idempotent_request は再送しても無駄、concurrent_idempotent_requests は後で再送可
- 分類は名前（エラー名の文字列）で行い、HTTP ステータスは補助にする。公式 Errors ページと SDK の定数でステータスが食い違う項目が実在するため、名前優先のほうが壊れにくい
- 送信・sleep・乱数を注入した純粋関数にすると、指数バックオフの待ち時間・送信者スワップ・タイムアウトまで、ネットワークも時計も使わずに決定的なテストで固定できる

---

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

```ts
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 本番運用ガイド](/blog/resend-transactional-email-production-guide)に、Next.js への組み込み方は [App Router / Route Handler 実装ガイド](/blog/resend-nextjs-app-router-route-handler-react-email-guide)にあります。本稿はその上で「落ちない送信」を作る回です。

---

## 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 と到達率のガイド](/blog/resend-domain-authentication-spf-dkim-dmarc-deliverability-guide)にまとめました。本稿は「壊れている間も落ちない」側を扱います。

---

## 2. Resend のエラーの実体を正確に知る

### 2.1 SDK は例外を投げない

Resend の Node SDK（このリポジトリでは `resend@6.4.1`、npm の最新は 2026-08-06 時点で 6.18.1）の共通レスポンス型はこうです。

```ts
// 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 も置く**」です。前者はクライアントの遅延生成で避けられます。

```ts
// モジュールスコープで 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 に入れても効かない）

```ts
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 本番運用ガイド](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions)で書いた「イベント ID から決定的にキーを導出する」がそのまま使えます。このサイトの Stripe Webhook も同じ形です。

```ts
// 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 分類器

```ts
/** 表に無い名前は 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_error` を `sender_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 指数バックオフ＋ジッター、そして派生キー

```ts
/** 決定的な指数の「床」＋最大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` を渡して待ち時間を厳密に検証できます。

```ts
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(...)` で止まったまま、プラットフォームが関数を殺すのを待つだけになり、そのとき失敗ログも一緒に消えます。

```ts
// 超過時に付ける合成エラー名。ステータスを持たないので retryable と判定される。
// それが正しいのは再試行が同じ冪等キーを使うから——タイムアウトした試行が実は
// 届いていたとしても、プロバイダ側の重複排除が再送を吸収する
export const TIMEOUT_ERROR_NAME = "request_timeout";

export const maxDuration = 25;   // リトライは複数往復＋バックオフを費やす
const SEND_TIMEOUT_MS = 8_000;   // 1回あたりの上限はその内側に収める
```

関数の実行モデルとタイムアウトの考え方は[Vercel Functions・Fluid Compute ガイド](/blog/vercel-functions-fluid-compute-streaming-cron-guide)にまとめています。

### 4.4 ループ本体：終了性を構造で保証する

```ts
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` をスワップ時にリセットしている理由も同じ筋です。**壊れた送信者で使い切った試行回数は、正常な送信者について何も語りません**。ここを引き継ぐと、救うために用意した経路が最初の一時的な失敗で終わります。

呼び出し側は、送信そのものをクロージャとして渡すだけです。

```ts
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 本番運用ガイド](/blog/resend-transactional-email-production-guide)にあるので、ここでは待ち時間の判断に使う4つだけを扱います。

SDK v6 のレスポンスには `headers` が含まれるので、これらは戻り値から直接読めます。

```ts
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 ガイド](/blog/resend-batch-scheduled-broadcasts-audiences-topics-unsubscribe-guide)へ。なお、Resend の 429 を受けてから慌てるより自分のエンドポイントで先に絞るほうが安く、その設計は[サーバーレスのレート制限ガイド](/blog/nextjs-serverless-rate-limiting-vercel-guide)にまとめています。

---

## 6. テスト：時計とネットワークを使わずに全分岐を通す

リトライのテストは「実際に待つ」と壊れます。3秒待つテストは遅く、しかも不安定です。**sleep と乱数を注入する**と、この問題は消えます。

```ts
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 署名検証とバウンス/苦情処理のガイド](/blog/resend-webhooks-signature-verification-bounce-complaint-guide)にあります。

### 6.2 本番での無害な検証

本番で確かめたいのは、たいてい「経路が生きているか」であって「メールが届くか」ではありません。無害な手が2つあります。

1. **同じ冪等キーで2回送る。** 24時間以内なら Resend は2回目を実際には送らず、同じレスポンスを返します。**同じメール ID が返れば、冪等キーが本当に効いていることの証明**になります。副作用ゼロで、しかもリトライ安全性そのものを検証できます。
2. **送信の手前で止まる経路を叩く。** このサイトの問い合わせ API は、スパム判定（滞在時間が閾値未満、またはハニーポットに入力あり）に該当したリクエストにも 200 を返して**送信せずに**終わります。つまり `elapsedMs` を 0 にしたリクエストを投げれば、レート制限・JSON パース・スキーマ検証まで本番で通しつつ、メールは1通も出ません。自分のハンドラにも、こうした「送信直前で止まる正当な経路」があるか確認してみてください。

---

## 7. 可観測性：フォールバック成功こそ warn で残す

リトライを入れると、**成功しているのに壊れている状態**が生まれます。送信者が拒否されているのにフォールバックで届いている、という状態です。訪問者には何も見えず、メールも届くので誰も気づきません。**そしてフォールバックも死んだ日に、初めて全損として顕在化します。**

```ts
// フォールバックで成功した＝設定した送信者は壊れているがリードは救えた。
// 訪問者にもメール本文にも痕跡が残らないので、ここで声を上げるしかない
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 実装ガイド](/blog/resend-nextjs-app-router-route-handler-react-email-guide) に譲ります。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 を選ぶかどうかの判断自体は別問題です。他サービスとの比較は[メール配信サービス選定ガイド](/blog/resend-vs-sendgrid-ses-postmark-email-service-selection-guide)にまとめました。

> この記事は [Resend 公式ドキュメント](https://resend.com/docs)（Idempotency Keys / Errors / Rate Limit / API Reference Introduction / Send test emails、2026年8月時点）と、実際にインストールされている Node SDK（`resend@6.4.1`、および npm 最新の 6.18.1）の型定義・バンドル実装に基づき、実運用の判断軸を加えて再構成したものです。ステータスコードや上限値は更新され、公式ページ間で食い違う箇所も実在するため、本番採用時は各公式ページで最新値をご確認ください。
