4.7 KiB
Sending Reliability
Ensuring emails are sent exactly once and handling failures gracefully.
Idempotency
Prevent duplicate emails when retrying failed requests.
The Problem
Network issues, timeouts, or server errors can leave you uncertain if an email was sent. Retrying without idempotency risks sending duplicates.
Solution: Idempotency Keys
Send a unique key with each request. If the same key is sent again, the server returns the original response instead of sending another email.
// Generate deterministic key based on the business event
const idempotencyKey = `password-reset-${userId}-${resetRequestId}`;
await resend.emails.send({
from: 'noreply@example.com',
to: user.email,
subject: 'Reset your password',
html: emailHtml,
}, {
headers: {
'Idempotency-Key': idempotencyKey
}
});
Key Generation Strategies
| Strategy | Example | Use When |
|---|---|---|
| Event-based | order-confirm-${orderId} |
One email per event (recommended) |
| Request-scoped | reset-${userId}-${resetRequestId} |
Retries within same request |
| UUID | crypto.randomUUID() |
No natural key (generate once, reuse on retry) |
Best practice: Use deterministic keys based on the business event. If you retry the same logical send, the same key must be generated. Avoid Date.now() or random values generated fresh on each attempt.
Key expiration: Idempotency keys are typically cached for 24 hours. Retries within this window return the original response. After expiration, the same key triggers a new send—so complete your retry logic well within 24 hours.
Retry Logic
Handle transient failures with exponential backoff.
When to Retry
| Error Type | Retry? | Notes |
|---|---|---|
| 5xx (server error) | ✅ Yes | Transient, likely to resolve |
| 429 (rate limit) | ✅ Yes | Wait for rate limit window |
| 4xx (client error) | ❌ No | Fix the request first |
| Network timeout | ✅ Yes | Transient |
| DNS failure | ✅ Yes | May be transient |
Exponential Backoff
async function sendWithRetry(emailData, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await resend.emails.send(emailData);
} catch (error) {
if (!isRetryable(error) || attempt === maxRetries - 1) {
throw error;
}
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
await sleep(delay + Math.random() * 1000); // Add jitter
}
}
}
function isRetryable(error) {
return error.statusCode >= 500 ||
error.statusCode === 429 ||
error.code === 'ETIMEDOUT';
}
Backoff schedule: 1s → 2s → 4s → 8s (with jitter to prevent thundering herd)
Error Handling
Common Error Codes
| Code | Meaning | Action |
|---|---|---|
| 400 | Bad request | Fix payload (invalid email, missing field) |
| 401 | Unauthorized | Check API key |
| 403 | Forbidden | Check permissions, domain verification |
| 404 | Not found | Check endpoint URL |
| 422 | Validation error | Fix request data |
| 429 | Rate limited | Back off, retry after delay |
| 500 | Server error | Retry with backoff |
| 503 | Service unavailable | Retry with backoff |
Error Handling Pattern
try {
const result = await resend.emails.send(emailData);
await logSuccess(result.id, emailData);
} catch (error) {
if (error.statusCode === 429) {
await queueForRetry(emailData, error.retryAfter);
} else if (error.statusCode >= 500) {
await queueForRetry(emailData);
} else {
await logFailure(error, emailData);
await alertOnCriticalEmail(emailData); // For password resets, etc.
}
}
Queuing for Reliability
For critical emails, use a queue to ensure delivery even if the initial send fails.
Benefits:
- Survives application restarts
- Automatic retry handling
- Rate limit management
- Audit trail
Simple pattern:
- Write email to queue/database with "pending" status
- Process queue, attempt send
- On success: mark "sent", store message ID
- On retryable failure: increment retry count, schedule retry
- On permanent failure: mark "failed", alert
Timeouts
Set appropriate timeouts to avoid hanging requests.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
await resend.emails.send(emailData, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
Recommended: 10-30 seconds for email API calls.
Related
- Webhooks & Events - Process delivery confirmations and failures
- List Management - Handle bounces and suppress invalid addresses