Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const vOnEmailEventArgs = v.object({

type Config = RuntimeConfig & {
webhookSecret: string;
rateLimitMs: number;
};

function getDefaultConfig(): Config {
Expand All @@ -46,6 +47,7 @@ function getDefaultConfig(): Config {
initialBackoffMs: 30000,
retryAttempts: 5,
testMode: true,
rateLimitMs: 600,
};
}

Expand Down Expand Up @@ -82,6 +84,13 @@ export type ResendOptions = {
*/
testMode?: boolean;

/**
* The minimum time in milliseconds between API calls to Resend.
* Default is 600ms. Only change this if you have a higher rate limit
* approved by Resend.
*/
rateLimitMs?: number;

/**
* A mutation to run after an email event occurs.
* The mutation will be passed the email id and the event.
Expand Down Expand Up @@ -112,6 +121,7 @@ async function configToRuntimeConfig(
initialBackoffMs: config.initialBackoffMs,
retryAttempts: config.retryAttempts,
testMode: config.testMode,
rateLimitMs: config.rateLimitMs,
onEmailEvent: onEmailEvent
? { fnHandle: await createFunctionHandle(onEmailEvent) }
: undefined,
Expand Down Expand Up @@ -225,6 +235,7 @@ export class Resend {
options?.initialBackoffMs ?? defaultConfig.initialBackoffMs,
retryAttempts: options?.retryAttempts ?? defaultConfig.retryAttempts,
testMode: options?.testMode ?? defaultConfig.testMode,
rateLimitMs: options?.rateLimitMs ?? defaultConfig.rateLimitMs,
};
if (options?.onEmailEvent) {
this.onEmailEvent = options.onEmailEvent;
Expand Down
5 changes: 5 additions & 0 deletions src/component/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ describe("sendEmail with templates", () => {
initialBackoffMs: 1000,
retryAttempts: 3,
testMode: true,
rateLimitMs: 600,
},
from: "test@resend.dev",
to: ["delivered@resend.dev"],
Expand Down Expand Up @@ -301,6 +302,7 @@ describe("sendEmail with templates", () => {
initialBackoffMs: 1000,
retryAttempts: 3,
testMode: true,
rateLimitMs: 600,
},
from: "test@resend.dev",
to: ["delivered@resend.dev"],
Expand All @@ -323,6 +325,7 @@ describe("sendEmail with templates", () => {
initialBackoffMs: 1000,
retryAttempts: 3,
testMode: true,
rateLimitMs: 600,
},
from: "test@resend.dev",
to: ["delivered@resend.dev"],
Expand Down Expand Up @@ -357,6 +360,7 @@ describe("sendEmail with templates", () => {
initialBackoffMs: 1000,
retryAttempts: 3,
testMode: true,
rateLimitMs: 600,
},
from: "test@resend.dev",
to: ["delivered@resend.dev"],
Expand All @@ -372,6 +376,7 @@ describe("sendEmail with templates", () => {
initialBackoffMs: 1000,
retryAttempts: 3,
testMode: true,
rateLimitMs: 600,
},
from: "test@resend.dev",
to: ["delivered@resend.dev"],
Expand Down
42 changes: 33 additions & 9 deletions src/component/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
vTemplate,
} from "./shared.js";
import type { FunctionHandle } from "convex/server";
import type { EmailEvent, RunMutationCtx, RunQueryCtx } from "./shared.js";
import type { EmailEvent } from "./shared.js";
import { isDeepEqual } from "remeda";
import schema from "./schema.js";
import { omit } from "convex-helpers";
Expand Down Expand Up @@ -81,7 +81,7 @@ const callbackPool = new Workpool(components.callbackWorkpool, {
});

// We rate limit our calls to the Resend API.
// FUTURE -- make this rate configurable if an account ups its sending rate with Resend.
// This is used for the default rate limit (600ms). Custom rates use timestamp-based limiting.
const resendApiRateLimiter = new RateLimiter(components.rateLimiter, {
resendApi: {
kind: "fixed window",
Expand Down Expand Up @@ -443,7 +443,8 @@ export const makeBatch = internalMutation({
}

// Okay, let's calculate rate limiting as best we can globally in this distributed system.
const delay = await getDelay(ctx);
const rateLimitMs = options.rateLimitMs ?? RESEND_ONE_CALL_EVERY_MS;
const delay = await getDelay(ctx, rateLimitMs);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Give the batch to the workpool! It will call the Resend batch API
// in a durable background action.
Expand Down Expand Up @@ -707,13 +708,36 @@ async function createResendBatchPayload(
}

const FIXED_WINDOW_DELAY = 100;
async function getDelay(ctx: RunMutationCtx & RunQueryCtx): Promise<number> {
const limit = await resendApiRateLimiter.limit(ctx, "resendApi", {
reserve: true,
});
//console.log(`RL: ${limit.ok} ${limit.retryAfter}`);
async function getDelay(
ctx: MutationCtx,
rateLimitMs: number,
): Promise<number> {
const jitter = Math.random() * FIXED_WINDOW_DELAY;
return limit.retryAfter ? limit.retryAfter + jitter : 0;

// Use default rate limiter for standard rate (most users)
if (rateLimitMs === RESEND_ONE_CALL_EVERY_MS) {
const limit = await resendApiRateLimiter.limit(ctx, "resendApi", {
reserve: true,
});
return limit.retryAfter ? limit.retryAfter + jitter : 0;
}

// For custom rates, use timestamp-based approach
const lastOptions = await ctx.db.query("lastOptions").unique();
const now = Date.now();
const lastCallTime = lastOptions?.lastApiCallTime ?? 0;
const elapsed = now - lastCallTime;
const delay = Math.max(0, rateLimitMs - elapsed);
const totalDelay = delay + jitter;

// Reserve the slot by updating timestamp (include jitter to avoid under-reservation)
if (lastOptions) {
await ctx.db.patch(lastOptions._id, {
lastApiCallTime: now + totalDelay,
});
}

return totalDelay;
}

// Helper to fetch content by id. We'll use batch apis here to avoid lots of action->query calls.
Expand Down
1 change: 1 addition & 0 deletions src/component/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default defineSchema({
}),
lastOptions: defineTable({
options: vOptions,
lastApiCallTime: v.optional(v.number()),
}),
deliveryEvents: defineTable({
emailId: v.id("emails"),
Expand Down
1 change: 1 addition & 0 deletions src/component/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const createTestRuntimeConfig = (): RuntimeConfig => ({
testMode: true,
initialBackoffMs: 1000,
retryAttempts: 3,
rateLimitMs: 600,
});

export const setupTestLastOptions = (
Expand Down
1 change: 1 addition & 0 deletions src/component/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const vOptions = v.object({
apiKey: v.string(),
testMode: v.boolean(),
onEmailEvent: v.optional(onEmailEvent),
rateLimitMs: v.number(),
});

export type RuntimeConfig = Infer<typeof vOptions>;
Expand Down
Loading