Fallbacks and retries
Retry transient provider errors with backoff, then fail over to a compatible backup adapter — two layers, one send call.
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.
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:
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.
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.
Retries
Configure retries once on the client with the retry option:
Prop
Type
The default backoff doubles from 100ms and caps at 2 seconds: 100, 200, 400, 800, 1600, 2000, 2000…
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:
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
For each send, the SDK builds a route from the selected adapter followed by the fallback adapters, with duplicate names removed:
[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
send options can reroute or re-budget a single send without touching the client:
// 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 for the same options.
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:
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 for the full error taxonomy, and hooks for observing attempts, retries, and route changes as they happen.
Idempotency keys
Retries and fallbacks mean the same logical email can be transmitted more than once. Give externally visible sends a stable key:
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-KeyHTTP header — the provider itself deduplicates repeated sends. - SMTP uses
message.idempotencyKeyas theMessage-IDheader 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
failingProvider and memoryProvider from the testing entry point make fallback behavior assertable without network calls:
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.
