Skip to content

Commit abc2de1

Browse files
authored
feat(api): add retry mechanism for sending emails (#1902)
- Updated the 'resend' package version from 4.0.1 to 6.6.0 for improved functionality. - Added new configuration options for email sending, including maxRetries, baseDelayMs, and minTimeBetweenEmailsMs. - Implemented rate limiting and retry logic in the NotificationService to handle rate limit errors more effectively. - Ensured compliance with coding standards, including optional chaining and nullish coalescing for safer property access.
1 parent 8319a24 commit abc2de1

File tree

4 files changed

+199
-21
lines changed

4 files changed

+199
-21
lines changed

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@
129129
"redis": "^4.6.13",
130130
"reflect-metadata": "^0.1.13",
131131
"replicate": "^1.1.0",
132-
"resend": "^4.0.1",
132+
"resend": "^6.6.0",
133133
"rxjs": "^7.2.0",
134134
"sharp": "^0.33.5",
135135
"strip-ansi": "^7.1.2",

apps/api/src/modules/config/app.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ export default () => ({
9393
sender: process.env.EMAIL_SENDER || 'Refly <[email protected]>',
9494
payloadMode: process.env.EMAIL_PAYLOAD_MODE || 'base64', // 'url' or 'base64'
9595
resendApiKey: process.env.RESEND_API_KEY || 're_123',
96+
maxRetries: Number.parseInt(process.env.EMAIL_MAX_RETRIES) || 3,
97+
baseDelayMs: Number.parseInt(process.env.EMAIL_BASE_DELAY_MS) || 500,
98+
minTimeBetweenEmailsMs: Number.parseInt(process.env.EMAIL_MIN_TIME_BETWEEN_MS) || 500, // 2 QPS = 500ms between emails
9699
},
97100
auth: {
98101
skipVerification: process.env.AUTH_SKIP_VERIFICATION === 'true' || false,

apps/api/src/modules/notification/notification.service.ts

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,111 @@
11
import { Injectable, Logger } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
3-
import { Attachment, Resend } from 'resend';
3+
import { Attachment, ErrorResponse, Resend } from 'resend';
44
import { SendEmailRequest, User } from '@refly/openapi-schema';
55
import { PrismaService } from '../common/prisma.service';
66
import { ParamsError } from '@refly/errors';
77
import { MiscService } from '../misc/misc.service';
8+
import { guard } from '../../utils/guard';
89

910
@Injectable()
1011
export class NotificationService {
1112
private readonly logger = new Logger(NotificationService.name);
1213
private readonly resend: Resend;
14+
private readonly maxRetries: number;
15+
private readonly baseDelayMs: number;
16+
private lastEmailSentAt = 0;
17+
private readonly minTimeBetweenEmailsMs: number;
1318

1419
constructor(
1520
private readonly configService: ConfigService,
1621
private readonly prisma: PrismaService,
1722
private readonly miscService: MiscService,
1823
) {
1924
this.resend = new Resend(this.configService.get('email.resendApiKey'));
25+
this.maxRetries = this.configService.get<number>('email.maxRetries') ?? 3;
26+
this.baseDelayMs = this.configService.get<number>('email.baseDelayMs') ?? 500;
27+
this.minTimeBetweenEmailsMs =
28+
this.configService.get<number>('email.minTimeBetweenEmailsMs') ?? 500;
29+
}
30+
31+
/**
32+
* Ensure minimum time between email sends to respect rate limits
33+
*/
34+
private async enforceRateLimit(): Promise<void> {
35+
const now = Date.now();
36+
const timeSinceLastEmail = now - this.lastEmailSentAt;
37+
38+
if (timeSinceLastEmail < this.minTimeBetweenEmailsMs) {
39+
const delayNeeded = this.minTimeBetweenEmailsMs - timeSinceLastEmail;
40+
this.logger.debug(`Rate limiting: waiting ${delayNeeded}ms before sending next email`);
41+
await new Promise((resolve) => setTimeout(resolve, delayNeeded));
42+
}
43+
44+
this.lastEmailSentAt = Date.now();
45+
}
46+
47+
/**
48+
* Check if error is rate limit related
49+
*/
50+
private isRateLimitError(error: ErrorResponse): boolean {
51+
return (
52+
error?.name === 'rate_limit_exceeded' ||
53+
error?.message?.toLowerCase().includes('rate') ||
54+
error?.message?.toLowerCase().includes('limit')
55+
);
56+
}
57+
58+
/**
59+
* Send email with retry logic for rate limit errors using guard.retry
60+
* @param emailData - Email data to send
61+
* @returns Resend response
62+
*/
63+
private async sendEmailWithRetry(emailData: {
64+
from: string;
65+
to: string;
66+
subject: string;
67+
html: string;
68+
attachments?: Attachment[];
69+
}): Promise<any> {
70+
return guard
71+
.retry(
72+
async () => {
73+
// Enforce rate limit before sending
74+
await this.enforceRateLimit();
75+
76+
const res = await this.resend.emails.send(emailData);
77+
78+
// Check for rate limit error in response
79+
if (res.error) {
80+
if (this.isRateLimitError(res.error)) {
81+
// Throw to trigger retry
82+
throw new Error(`Rate limit exceeded: ${res.error.message}`);
83+
}
84+
// Other errors should not be retried
85+
throw new Error(res.error.message);
86+
}
87+
88+
return res;
89+
},
90+
{
91+
maxAttempts: this.maxRetries,
92+
initialDelay: this.baseDelayMs,
93+
maxDelay: this.baseDelayMs * 2 ** (this.maxRetries - 1),
94+
backoffFactor: 2,
95+
retryIf: (error: any) => this.isRateLimitError(error),
96+
onRetry: (error: any, attempt: number) => {
97+
this.logger.warn(
98+
`Rate limit error detected. Retrying... (attempt ${attempt}/${this.maxRetries}): ${error.message}`,
99+
);
100+
},
101+
},
102+
)
103+
.orThrow((error: any) => {
104+
this.logger.error(
105+
`Failed to send email after ${this.maxRetries} retries: ${error.message}`,
106+
);
107+
return new Error(`Failed to send email: ${error.message}`);
108+
});
20109
}
21110

22111
/**
@@ -171,20 +260,16 @@ export class NotificationService {
171260
attachments = await Promise.all(attachmentUrls.map((url) => this.processAttachmentURL(url)));
172261
}
173262

174-
const res = await this.resend.emails.send({
263+
await this.sendEmailWithRetry({
175264
from: sender,
176265
to: receiver,
177266
subject,
178267
html,
179268
attachments,
180269
});
181270

182-
this.logger.log(`Email sent successfully to ${receiver}`);
183-
184-
if (res.error) {
185-
throw new Error(res.error?.message);
186-
}
187-
188-
this.logger.log(`Email sent to successfully in ${new Date().getTime() - now.getTime()}ms`);
271+
this.logger.log(
272+
`Email sent successfully to ${receiver} in ${new Date().getTime() - now.getTime()}ms`,
273+
);
189274
}
190275
}

0 commit comments

Comments
 (0)