Back to Novu

Sending Reliability

.agents/skills/email-best-practices/resources/sending-reliability.md

3.15.04.7 KB
Original Source

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.

typescript
// Generate deterministic key based on the business event
const idempotencyKey = `password-reset-${userId}-${resetRequestId}`;

await resend.emails.send({
  from: '[email protected]',
  to: user.email,
  subject: 'Reset your password',
  html: emailHtml,
}, {
  headers: {
    'Idempotency-Key': idempotencyKey
  }
});

Key Generation Strategies

StrategyExampleUse When
Event-basedorder-confirm-${orderId}One email per event (recommended)
Request-scopedreset-${userId}-${resetRequestId}Retries within same request
UUIDcrypto.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 TypeRetry?Notes
5xx (server error)✅ YesTransient, likely to resolve
429 (rate limit)✅ YesWait for rate limit window
4xx (client error)❌ NoFix the request first
Network timeout✅ YesTransient
DNS failure✅ YesMay be transient

Exponential Backoff

typescript
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

CodeMeaningAction
400Bad requestFix payload (invalid email, missing field)
401UnauthorizedCheck API key
403ForbiddenCheck permissions, domain verification
404Not foundCheck endpoint URL
422Validation errorFix request data
429Rate limitedBack off, retry after delay
500Server errorRetry with backoff
503Service unavailableRetry with backoff

Error Handling Pattern

typescript
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:

  1. Write email to queue/database with "pending" status
  2. Process queue, attempt send
  3. On success: mark "sent", store message ID
  4. On retryable failure: increment retry count, schedule retry
  5. On permanent failure: mark "failed", alert

Timeouts

Set appropriate timeouts to avoid hanging requests.

typescript
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.