Skip to content

Commit 8981cd1

Browse files
committed
add configurable rate limit
1 parent 9c380e6 commit 8981cd1

File tree

5 files changed

+47
-12
lines changed

5 files changed

+47
-12
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ Check out the [docstrings](./src/client/index.ts), but notable options include:
163163
develop your project, `testMode` is default **true**. You need to explicitly
164164
set this to `false` for the component to allow you to enqueue emails to
165165
artibrary addresses.
166+
- `rateLimitPerSecond`: The rate limit in requests per second for the Resend
167+
API. Defaults to 2 requests per second, which is the default Resend rate
168+
limit. If your Resend account has a higher rate limit (e.g., 100 requests per
169+
second), set this value accordingly. For example:
170+
```ts
171+
export const resend: Resend = new Resend(components.resend, {
172+
rateLimitPerSecond: 100,
173+
});
174+
```
166175
- `onEmailEvent`: Your email event callback, as outlined above! Check out the
167176
[docstrings](./src/client/index.ts) for details on the events that are
168177
emitted.

src/client/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function getDefaultConfig(): Config {
4646
initialBackoffMs: 30000,
4747
retryAttempts: 5,
4848
testMode: true,
49+
rateLimitPerSecond: 2,
4950
};
5051
}
5152

@@ -94,6 +95,14 @@ export type ResendOptions = {
9495
event: EmailEvent;
9596
}
9697
> | null;
98+
99+
/**
100+
* The rate limit in requests per second for the Resend API.
101+
* Defaults to 2 requests per second, which is the default Resend rate limit.
102+
* If your Resend account has a higher rate limit (e.g., 100 requests per second),
103+
* set this value accordingly.
104+
*/
105+
rateLimitPerSecond?: number;
97106
};
98107

99108
async function configToRuntimeConfig(
@@ -112,6 +121,7 @@ async function configToRuntimeConfig(
112121
initialBackoffMs: config.initialBackoffMs,
113122
retryAttempts: config.retryAttempts,
114123
testMode: config.testMode,
124+
rateLimitPerSecond: config.rateLimitPerSecond,
115125
onEmailEvent: onEmailEvent
116126
? { fnHandle: await createFunctionHandle(onEmailEvent) }
117127
: undefined,
@@ -225,6 +235,8 @@ export class Resend {
225235
options?.initialBackoffMs ?? defaultConfig.initialBackoffMs,
226236
retryAttempts: options?.retryAttempts ?? defaultConfig.retryAttempts,
227237
testMode: options?.testMode ?? defaultConfig.testMode,
238+
rateLimitPerSecond:
239+
options?.rateLimitPerSecond ?? defaultConfig.rateLimitPerSecond,
228240
};
229241
if (options?.onEmailEvent) {
230242
this.onEmailEvent = options.onEmailEvent;

src/component/lib.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ const BASE_BATCH_DELAY = 1000;
3434
const BATCH_SIZE = 100;
3535
const EMAIL_POOL_SIZE = 4;
3636
const CALLBACK_POOL_SIZE = 4;
37-
const RESEND_ONE_CALL_EVERY_MS = 600; // Half the stated limit, but it keeps us sane.
3837
const FINALIZED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
3938
const FINALIZED_EPOCH = Number.MAX_SAFE_INTEGER;
4039
const ABANDONED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
40+
const DEFAULT_RATE_LIMIT_PER_SECOND = 2;
4141

4242
const RESEND_TEST_EMAILS = ["delivered", "bounced", "complained"];
4343

@@ -70,7 +70,7 @@ function getSegment(now: number) {
7070
return Math.floor(now / SEGMENT_MS);
7171
}
7272

73-
// Four threads is more than enough, especially given the low rate limiting.
73+
// Four threads is more than enough for the email sending workpool.
7474
const emailPool = new Workpool(components.emailWorkpool, {
7575
maxParallelism: EMAIL_POOL_SIZE,
7676
});
@@ -81,14 +81,7 @@ const callbackPool = new Workpool(components.callbackWorkpool, {
8181
});
8282

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

9386
// Periodic background job to clean up old emails that have already
9487
// been delivered, bounced, what have you.
@@ -443,7 +436,12 @@ export const makeBatch = internalMutation({
443436
}
444437

445438
// Okay, let's calculate rate limiting as best we can globally in this distributed system.
446-
const delay = await getDelay(ctx);
439+
// Handle backward compatibility: old database records may not have rateLimitPerSecond.
440+
// Use fallback to default (2 req/s) for migration safety.
441+
const delay = await getDelay(
442+
ctx,
443+
options.rateLimitPerSecond ?? DEFAULT_RATE_LIMIT_PER_SECOND,
444+
);
447445

448446
// Give the batch to the workpool! It will call the Resend batch API
449447
// in a durable background action.
@@ -707,9 +705,23 @@ async function createResendBatchPayload(
707705
}
708706

709707
const FIXED_WINDOW_DELAY = 100;
710-
async function getDelay(ctx: RunMutationCtx & RunQueryCtx): Promise<number> {
708+
async function getDelay(
709+
ctx: RunMutationCtx & RunQueryCtx,
710+
rateLimitPerSecond: number,
711+
): Promise<number> {
712+
// Calculate the period in milliseconds based on the configured rate limit.
713+
// We use a conservative approach: allow 1 request per period.
714+
// For example, 100 requests/second = 10ms period, 2 requests/second = 500ms period.
715+
// Ensure minimum period of 1ms to allow high rate limits.
716+
const periodMs = Math.max(1, Math.floor(1000 / rateLimitPerSecond));
717+
711718
const limit = await resendApiRateLimiter.limit(ctx, "resendApi", {
712719
reserve: true,
720+
config: {
721+
kind: "fixed window",
722+
period: periodMs,
723+
rate: 1,
724+
},
713725
});
714726
//console.log(`RL: ${limit.ok} ${limit.retryAfter}`);
715727
const jitter = Math.random() * FIXED_WINDOW_DELAY;

src/component/setup.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export const createTestRuntimeConfig = (): RuntimeConfig => ({
155155
testMode: true,
156156
initialBackoffMs: 1000,
157157
retryAttempts: 3,
158+
rateLimitPerSecond: 2,
158159
});
159160

160161
export const setupTestLastOptions = (

src/component/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const vOptions = v.object({
3737
retryAttempts: v.number(),
3838
apiKey: v.string(),
3939
testMode: v.boolean(),
40+
rateLimitPerSecond: v.optional(v.number()),
4041
onEmailEvent: v.optional(onEmailEvent),
4142
});
4243

0 commit comments

Comments
 (0)