Skip to content

Commit b9b5c77

Browse files
Merge pull request #1318 from kaizercodes/fix/issue-1176-retry-failed-email-sends-with-capped-backoff
fix: retry failed email sends with capped backoff
2 parents 73cf8ed + 35ac1ee commit b9b5c77

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

src/lib/email/provider.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { EmailMessage, EmailProvider, EmailProviderType, EmailSendResult } from
33
const DEFAULT_FROM_EMAIL = process.env.EMAIL_FROM_ADDRESS ?? '[email protected]';
44
const DEFAULT_FROM_NAME = process.env.EMAIL_FROM_NAME ?? 'TeachLink';
55

6+
const MAX_RETRIES = 3;
7+
const BASE_BACKOFF_MS = 500;
8+
const MAX_BACKOFF_MS = 5000;
9+
610
function asArray<T>(value: T | T[]): T[] {
711
return Array.isArray(value) ? value : [value];
812
}
@@ -11,6 +15,22 @@ function resolveFrom(message: EmailMessage) {
1115
return message.from ?? { email: DEFAULT_FROM_EMAIL, name: DEFAULT_FROM_NAME };
1216
}
1317

18+
function wait(ms: number): Promise<void> {
19+
return new Promise((resolve) => setTimeout(resolve, ms));
20+
}
21+
22+
function isRetryableResult(result: EmailSendResult): boolean {
23+
if (result.success) return false;
24+
if (result.error?.includes('SENDGRID_API_KEY is not configured')) return false;
25+
const match = /SendGrid error (\\d+)/.exec(result.error ?? '');
26+
if (match) {
27+
const status = parseInt(match[1]);
28+
return status === 408 || status === 429 || status >= 500;
29+
}
30+
// Network errors and other transient exceptions should be retried.
31+
return true;
32+
}
33+
1434
class SendGridProvider implements EmailProvider {
1535
readonly type: EmailProviderType = 'sendgrid';
1636

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

43+
let lastResult: EmailSendResult = {
44+
success: false,
45+
provider: this.type,
46+
error: 'Email send failed after retries',
47+
};
48+
let delay = BASE_BACKOFF_MS;
49+
50+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
51+
lastResult = await this.doSend(message, apiKey);
52+
53+
if (lastResult.success || !isRetryableResult(lastResult)) {
54+
return lastResult;
55+
}
56+
57+
if (attempt < MAX_RETRIES) {
58+
await wait(delay);
59+
delay = Math.min(delay * 2, MAX_BACKOFF_MS);
60+
}
61+
}
62+
63+
return lastResult;
64+
}
65+
66+
private async doSend(message: EmailMessage, apiKey: string): Promise<EmailSendResult> {
2367
try {
2468
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
2569
method: 'POST',

src/lib/email/queue.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const DEFAULT_OPTIONS: QueueOptions = {
1212
maxConcurrent: 2,
1313
};
1414

15+
/**
16+
* Upper bound for exponential backoff between retries, so a stuck queue
17+
* doesn't stall with ever-growing delays.
18+
*/
19+
const DEFAULT_MAX_RETRY_DELAY_MS = 30_000;
20+
1521
/**
1622
* How long a completed send is remembered for dedupe purposes.
1723
*
@@ -158,7 +164,11 @@ export class EmailQueue {
158164
}
159165

160166
if (job.attempts < this.options.maxRetries) {
161-
await delay(this.options.retryDelayMs * job.attempts);
167+
const backoff = Math.min(
168+
this.options.retryDelayMs * 2 ** (job.attempts - 1),
169+
DEFAULT_MAX_RETRY_DELAY_MS,
170+
);
171+
await delay(backoff);
162172
}
163173
}
164174

0 commit comments

Comments
 (0)