Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/lib/email/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { EmailMessage, EmailProvider, EmailProviderType, EmailSendResult } from
const DEFAULT_FROM_EMAIL = process.env.EMAIL_FROM_ADDRESS ?? '[email protected]';
const DEFAULT_FROM_NAME = process.env.EMAIL_FROM_NAME ?? 'TeachLink';

const MAX_RETRIES = 3;
const BASE_BACKOFF_MS = 500;
const MAX_BACKOFF_MS = 5000;

function asArray<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value];
}
Expand All @@ -11,6 +15,22 @@ function resolveFrom(message: EmailMessage) {
return message.from ?? { email: DEFAULT_FROM_EMAIL, name: DEFAULT_FROM_NAME };
}

function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function isRetryableResult(result: EmailSendResult): boolean {
if (result.success) return false;
if (result.error?.includes('SENDGRID_API_KEY is not configured')) return false;
const match = /SendGrid error (\\d+)/.exec(result.error ?? '');
if (match) {
const status = parseInt(match[1]);
return status === 408 || status === 429 || status >= 500;
}
// Network errors and other transient exceptions should be retried.
return true;
}

class SendGridProvider implements EmailProvider {
readonly type: EmailProviderType = 'sendgrid';

Expand All @@ -20,6 +40,30 @@ class SendGridProvider implements EmailProvider {
return { success: false, provider: this.type, error: 'SENDGRID_API_KEY is not configured' };
}

let lastResult: EmailSendResult = {
success: false,
provider: this.type,
error: 'Email send failed after retries',
};
let delay = BASE_BACKOFF_MS;

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
lastResult = await this.doSend(message, apiKey);

if (lastResult.success || !isRetryableResult(lastResult)) {
return lastResult;
}

if (attempt < MAX_RETRIES) {
await wait(delay);
delay = Math.min(delay * 2, MAX_BACKOFF_MS);
}
}

return lastResult;
}

private async doSend(message: EmailMessage, apiKey: string): Promise<EmailSendResult> {
try {
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
Expand Down
12 changes: 11 additions & 1 deletion src/lib/email/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ const DEFAULT_OPTIONS: QueueOptions = {
maxConcurrent: 2,
};

/**
* Upper bound for exponential backoff between retries, so a stuck queue
* doesn't stall with ever-growing delays.
*/
const DEFAULT_MAX_RETRY_DELAY_MS = 30_000;

/**
* How long a completed send is remembered for dedupe purposes.
*
Expand Down Expand Up @@ -158,7 +164,11 @@ export class EmailQueue {
}

if (job.attempts < this.options.maxRetries) {
await delay(this.options.retryDelayMs * job.attempts);
const backoff = Math.min(
this.options.retryDelayMs * 2 ** (job.attempts - 1),
DEFAULT_MAX_RETRY_DELAY_MS,
);
await delay(backoff);
}
}

Expand Down
Loading