# Fallbacks and retries (/docs/v/0.6.1/concepts/fallbacks-and-retries)



A failing send gets two distinct layers of recovery. **Retries** re-attempt the *same* adapter when an error looks transient (rate limit, timeout, 5xx). **Fallback** moves to the *next* adapter only after the current one has finally failed. Both are off by default — `retries` is `0` and `fallback` is empty.

```ts title="lib/email.ts"
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
import { smtp } from "@opencoredev/email-sdk/smtp";

const email = createEmailClient({
  adapters: [
    resend({ apiKey: process.env.RESEND_API_KEY! }),
    smtp({
      host: process.env.SMTP_HOST!,
      auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
    }),
  ],
  retry: { retries: 2 },
  fallback: ["smtp"],
});
```

With that client, one `send` call that keeps hitting rate limits plays out like this:

```txt
resend  attempt 1 → 429 (retryable)   wait 100ms
resend  attempt 2 → 429               wait 200ms
resend  attempt 3 → 429               retries exhausted → onError("resend")
smtp    attempt 1 → accepted          response.provider === "smtp"
```

**The retry budget resets for each adapter on the route.** The fallback adapter gets its own `retries: 2`, so the worst case for this client is six provider attempts — size your retry budget with the whole route in mind.

<Callout type="warn" title="Fallback routes must be field-compatible">
  A backup adapter only helps if it supports every `EmailMessage` field your messages actually use
  — adapters reject unsupported fields instead of silently dropping them, so an incompatible
  fallback fails too. Pick routes from the [field support matrix](/docs/v/0.6.1/adapters/field-support).
</Callout>

## Retries [#retries]

Configure retries once on the client with the `retry` option:

<TypeTable
  type="{
  retries: {
    description: &#x22;Extra attempts per adapter after the first one fails.&#x22;,
    type: &#x22;number&#x22;,
    default: &#x22;0&#x22;,
  },
  delay: {
    description: &#x22;Backoff in milliseconds before the next attempt.&#x22;,
    type: &#x22;(attempt: number, error: unknown) => number&#x22;,
    default: &#x22;min(100 * 2^(attempt - 1), 2000)&#x22;,
  },
  shouldRetry: {
    description: &#x22;Decides whether an error is worth retrying.&#x22;,
    type: &#x22;(error: unknown, attempt: number) => boolean&#x22;,
    default: &#x22;isRetryableEmailError&#x22;,
  },
}"
/>

The default backoff doubles from 100ms and caps at 2 seconds: 100, 200, 400, 800, 1600, 2000, 2000…

### What retries by default [#what-retries-by-default]

The default `shouldRetry` is `isRetryableEmailError`: it retries only errors the SDK has marked `retryable: true`. Adapters set that flag for:

| Error                           | Why it retries                                        |
| ------------------------------- | ----------------------------------------------------- |
| HTTP `408`, `409`, `425`, `429` | Timeout, conflict, too-early, rate limit              |
| HTTP `5xx`                      | Provider-side failure                                 |
| Network errors                  | `ECONNRESET`-style failures, `fetch failed`, timeouts |

Everything else — `401`, `422`, validation errors, bugs in your code — fails immediately. To change the policy, supply your own `shouldRetry` or `delay`:

```ts
const email = createEmailClient({
  adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
  retry: {
    retries: 3,
    delay: (attempt) => 250 * attempt + Math.random() * 100, // linear + jitter
  },
});
```

## Fallback routes [#fallback-routes]

For each send, the SDK builds a route from the selected adapter followed by the fallback adapters, with duplicate names removed:

```txt
[adapter, ...fallbackAdapters]
```

The selected adapter comes from the per-send `adapter` option or the client's `defaultAdapter` (the first registered adapter unless you set it). Fallbacks come from the per-send `fallbackAdapters` option or the client-level `fallback` list. A route name that is not registered throws `EmailProviderNotFoundError` when the route reaches it.

Fallback is not limited to retryable errors. Retryability decides whether the *same* adapter gets another attempt; fallback fires on *any* final adapter failure — including a non-retryable `401` on the first attempt.

## Per-send overrides [#per-send-overrides]

`send` options can reroute or re-budget a single send without touching the client:

```ts
// More retries for a payment receipt, different backup route
await email.send(message, {
  retries: 4,
  adapter: "resend",
  fallbackAdapters: ["postmark"],
});

// Disable the client-level fallback for one send
await email.send(message, { fallbackAdapters: [] });
```

An empty `fallbackAdapters` array is the right tool when one message uses fields the default backup route cannot represent. `provider` and `fallbackProviders` are [aliases](/docs/v/0.6.1/concepts/adapter-model#naming-aliases) for the same options.

## When every route fails [#when-every-route-fails]

If only one adapter was attempted, its error is rethrown as-is. If multiple routes failed, the SDK throws an `EmailSdkError` with code `all_providers_failed` and every per-adapter failure collected in `details`:

```ts
import { EmailSdkError } from "@opencoredev/email-sdk";

try {
  await email.send(message);
} catch (error) {
  if (error instanceof EmailSdkError && error.code === "all_providers_failed") {
    console.error(error.details); // one failure per attempted adapter, in route order
  }
}
```

See the [errors reference](/docs/v/0.6.1/reference/errors) for the full error taxonomy, and [hooks](/docs/v/0.6.1/concepts/hooks) for observing attempts, retries, and route changes as they happen.

## Idempotency keys [#idempotency-keys]

Retries and fallbacks mean the same logical email can be transmitted more than once. Give externally visible sends a stable key:

```ts
await email.send(message, {
  idempotencyKey: "receipt:order_123",
});
```

The key (from `send` options, falling back to `message.idempotencyKey`) is passed to the adapter on every attempt. What happens next depends on the adapter:

* **Resend** is the only adapter that transmits it natively, as the `Idempotency-Key` HTTP header — the provider itself deduplicates repeated sends.
* **SMTP** uses `message.idempotencyKey` as the `Message-ID` header value, so downstream mail systems can recognize duplicates.
* Every other adapter ignores it on the wire.

Even where the provider ignores the key, set one anyway: it gives your own pipeline — queues, workers, hook-based logging — a stable identity, so application-level retries and re-enqueues stay safe to deduplicate.

## Testing the route [#testing-the-route]

`failingProvider` and `memoryProvider` from the testing entry point make fallback behavior assertable without network calls:

```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { failingProvider, memoryProvider } from "@opencoredev/email-sdk/testing";

const backup = memoryProvider("backup");
const email = createEmailClient({
  adapters: [failingProvider("primary"), backup],
  fallback: ["backup"],
});

const response = await email.send({
  from: "Acme <hello@acme.com>",
  to: "user@example.com",
  subject: "Fallback",
  text: "Hello",
});

expect(response.provider).toBe("backup");
expect(backup.raw.sent).toHaveLength(1);
```

Test with the same message shape production sends — that is what proves the routes are compatible.

## Next [#next]

<Cards>
  <Card title="Field support" href="/docs/v/0.6.1/adapters/field-support" description="The matrix for choosing compatible primary and fallback routes." />

  <Card title="Hooks" href="/docs/v/0.6.1/concepts/hooks" description="Watch attempts, retries, and fallback routing in real time." />

  <Card title="Production send pipeline" href="/docs/v/0.6.1/guides/production-send-pipeline" description="Routing, retries, fallbacks, observability, and tests assembled end to end." />
</Cards>
