Skip to content

Commit f55f45c

Browse files
committed
Add rate limiting configuration for email sending
- Introduced `getResendRateLimitConfig` function to retrieve and calculate email sending rate limits. - Added `setResendRateLimit` mutation to allow dynamic updates of the email sending rate limit. - Updated schema to include `resendOptions` for storing rate limit configurations. - Enhanced shared types with `vResendOptions` for validation of rate limit settings. - Refactored rate limiter initialization to support dynamic configurations.
1 parent a37df33 commit f55f45c

File tree

4 files changed

+60
-11
lines changed

4 files changed

+60
-11
lines changed

src/component/_generated/component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
154154
string,
155155
Name
156156
>;
157+
setResendRateLimit: FunctionReference<
158+
"mutation",
159+
"internal",
160+
{ rateLimitEmailsPerSecond: number },
161+
null,
162+
Name
163+
>;
157164
updateManualEmail: FunctionReference<
158165
"mutation",
159166
"internal",

src/component/lib.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
type ActionCtx,
99
} from "./_generated/server.js";
1010
import { Workpool } from "@convex-dev/workpool";
11-
import { RateLimiter } from "@convex-dev/rate-limiter";
11+
import { RateLimiter, SECOND } from "@convex-dev/rate-limiter";
12+
import type { RateLimitConfig } from "@convex-dev/rate-limiter";
1213
import { api, components, internal } from "./_generated/api.js";
1314
import { internalMutation } from "./_generated/server.js";
1415
import { type Id, type Doc } from "./_generated/dataModel.js";
@@ -33,6 +34,8 @@ const BATCH_SIZE = 100;
3334
const EMAIL_POOL_SIZE = 4;
3435
const CALLBACK_POOL_SIZE = 4;
3536
const RESEND_ONE_CALL_EVERY_MS = 600; // Half the stated limit, but it keeps us sane.
37+
const DEFAULT_EMAILS_PER_SECOND = SECOND / RESEND_ONE_CALL_EVERY_MS;
38+
const MIN_EMAILS_PER_SECOND = 0.1;
3639
const FINALIZED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
3740
const FINALIZED_EPOCH = Number.MAX_SAFE_INTEGER;
3841
const ABANDONED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
@@ -79,14 +82,7 @@ const callbackPool = new Workpool(components.callbackWorkpool, {
7982
});
8083

8184
// We rate limit our calls to the Resend API.
82-
// FUTURE -- make this rate configurable if an account ups its sending rate with Resend.
83-
const resendApiRateLimiter = new RateLimiter(components.rateLimiter, {
84-
resendApi: {
85-
kind: "fixed window",
86-
period: RESEND_ONE_CALL_EVERY_MS,
87-
rate: 1,
88-
},
89-
});
85+
const resendApiRateLimiter = new RateLimiter(components.rateLimiter);
9086

9187
// Enqueue an email to be send. A background job will grab batches
9288
// of emails and enqueue them to be sent by the workpool.
@@ -190,6 +186,23 @@ export const sendEmail = mutation({
190186
},
191187
});
192188

189+
async function getResendRateLimitConfig(
190+
ctx: MutationCtx,
191+
): Promise<RateLimitConfig> {
192+
const record = await ctx.db.query("resendOptions").unique();
193+
const raw = record?.options.rateLimitEmailsPerSecond;
194+
const emailsPerSecond =
195+
typeof raw === "number" && Number.isFinite(raw) && raw > 0
196+
? Math.max(raw, MIN_EMAILS_PER_SECOND)
197+
: Math.max(DEFAULT_EMAILS_PER_SECOND, MIN_EMAILS_PER_SECOND);
198+
return {
199+
kind: "token bucket",
200+
rate: emailsPerSecond,
201+
period: SECOND,
202+
capacity: Math.max(emailsPerSecond, 1),
203+
};
204+
}
205+
193206
export const createManualEmail = mutation({
194207
args: {
195208
from: v.string(),
@@ -271,6 +284,26 @@ export const cancelEmail = mutation({
271284
},
272285
});
273286

287+
export const setResendRateLimit = mutation({
288+
args: { rateLimitEmailsPerSecond: v.number() },
289+
returns: v.null(),
290+
handler: async (ctx, { rateLimitEmailsPerSecond }) => {
291+
if (
292+
!Number.isFinite(rateLimitEmailsPerSecond) ||
293+
rateLimitEmailsPerSecond <= 0
294+
) {
295+
throw new Error("rateLimitEmailsPerSecond must be a positive number");
296+
}
297+
const existing = await ctx.db.query("resendOptions").unique();
298+
const options = { rateLimitEmailsPerSecond };
299+
if (existing) {
300+
await ctx.db.patch(existing._id, { options });
301+
} else {
302+
await ctx.db.insert("resendOptions", { options });
303+
}
304+
},
305+
});
306+
274307
// Get the status of an email.
275308
export const getStatus = query({
276309
args: {
@@ -684,9 +717,11 @@ async function createResendBatchPayload(
684717
}
685718

686719
const FIXED_WINDOW_DELAY = 100;
687-
async function getDelay(ctx: RunMutationCtx & RunQueryCtx): Promise<number> {
720+
async function getDelay(ctx: MutationCtx): Promise<number> {
721+
const config = await getResendRateLimitConfig(ctx);
688722
const limit = await resendApiRateLimiter.limit(ctx, "resendApi", {
689723
reserve: true,
724+
config,
690725
});
691726
//console.log(`RL: ${limit.ok} ${limit.retryAfter}`);
692727
const jitter = Math.random() * FIXED_WINDOW_DELAY;

src/component/schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { defineSchema, defineTable } from "convex/server";
22
import { v } from "convex/values";
3-
import { vOptions, vStatus } from "./shared.js";
3+
import { vOptions, vResendOptions, vStatus } from "./shared.js";
44

55
export default defineSchema({
66
content: defineTable({
@@ -15,6 +15,9 @@ export default defineSchema({
1515
lastOptions: defineTable({
1616
options: vOptions,
1717
}),
18+
resendOptions: defineTable({
19+
options: vResendOptions,
20+
}),
1821
deliveryEvents: defineTable({
1922
emailId: v.id("emails"),
2023
resendId: v.string(),

src/component/shared.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export const vOptions = v.object({
4141

4242
export type RuntimeConfig = Infer<typeof vOptions>;
4343

44+
export const vResendOptions = v.object({
45+
rateLimitEmailsPerSecond: v.number(),
46+
});
47+
4448
const commonFields = {
4549
broadcast_id: v.optional(v.string()),
4650
created_at: v.string(),

0 commit comments

Comments
 (0)