Skip to content

Commit b691aa7

Browse files
committed
Adds a rate limit stress test task to references
1 parent 54ee825 commit b691aa7

File tree

1 file changed

+257
-0
lines changed

1 file changed

+257
-0
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { logger, task, tasks, RateLimitError } from "@trigger.dev/sdk/v3";
2+
import { setTimeout } from "timers/promises";
3+
4+
/**
5+
* A simple no-op task that does minimal work.
6+
* Used as the target for rate limit stress testing.
7+
*/
8+
export const noopTask = task({
9+
id: "noop-task",
10+
retry: { maxAttempts: 1 },
11+
run: async (payload: { index: number }) => {
12+
return { index: payload.index, timestamp: Date.now() };
13+
},
14+
});
15+
16+
/**
17+
* Stress test task that triggers many runs rapidly to hit the API rate limit.
18+
* Fires triggers as fast as possible for a set duration, then stops.
19+
*
20+
* Note: Already-triggered runs will continue to execute after the test completes.
21+
*
22+
* Default rate limits (per environment API key):
23+
* - Free: 1,200 runs bucket, refills 100 runs/10 sec
24+
* - Hobby/Pro: 5,000 runs bucket, refills 500 runs/5 sec
25+
*
26+
* Run with: `npx trigger.dev@latest dev` then trigger this task from the dashboard
27+
*/
28+
export const rateLimitStressTest = task({
29+
id: "rate-limit-stress-test",
30+
maxDuration: 120,
31+
run: async (payload: {
32+
/** How long to run the test in seconds (default: 5) */
33+
durationSeconds?: number;
34+
/** How many triggers to fire in parallel per batch (default: 100) */
35+
batchSize?: number;
36+
}) => {
37+
const durationSeconds = payload.durationSeconds ?? 5;
38+
const batchSize = payload.batchSize ?? 100;
39+
const durationMs = durationSeconds * 1000;
40+
41+
logger.info("Starting rate limit stress test", {
42+
durationSeconds,
43+
batchSize,
44+
});
45+
46+
const start = Date.now();
47+
let totalAttempted = 0;
48+
let totalSuccess = 0;
49+
let totalRateLimited = 0;
50+
let totalOtherErrors = 0;
51+
let batchCount = 0;
52+
53+
// Keep firing batches until time runs out
54+
while (Date.now() - start < durationMs) {
55+
batchCount++;
56+
const batchStart = Date.now();
57+
const elapsed = batchStart - start;
58+
const remaining = durationMs - elapsed;
59+
60+
logger.info(`Batch ${batchCount} starting`, {
61+
elapsedMs: elapsed,
62+
remainingMs: remaining,
63+
totalAttempted,
64+
totalSuccess,
65+
totalRateLimited,
66+
});
67+
68+
// Fire a batch of triggers
69+
const promises = Array.from({ length: batchSize }, async (_, i) => {
70+
// Check if we've exceeded time before each trigger
71+
if (Date.now() - start >= durationMs) {
72+
return { skipped: true };
73+
}
74+
75+
const index = totalAttempted + i;
76+
try {
77+
await tasks.trigger<typeof noopTask>("noop-task", { index });
78+
return { success: true, rateLimited: false };
79+
} catch (error) {
80+
if (error instanceof RateLimitError) {
81+
return { success: false, rateLimited: true, resetInMs: error.millisecondsUntilReset };
82+
}
83+
return { success: false, rateLimited: false };
84+
}
85+
});
86+
87+
const results = await Promise.all(promises);
88+
89+
const batchSuccess = results.filter((r) => "success" in r && r.success).length;
90+
const batchRateLimited = results.filter((r) => "rateLimited" in r && r.rateLimited).length;
91+
const batchOtherErrors = results.filter(
92+
(r) => "success" in r && !r.success && !("rateLimited" in r && r.rateLimited)
93+
).length;
94+
const batchSkipped = results.filter((r) => "skipped" in r && r.skipped).length;
95+
96+
totalAttempted += batchSize - batchSkipped;
97+
totalSuccess += batchSuccess;
98+
totalRateLimited += batchRateLimited;
99+
totalOtherErrors += batchOtherErrors;
100+
101+
// Log rate limit hits
102+
const rateLimitedResult = results.find((r) => "rateLimited" in r && r.rateLimited);
103+
if (rateLimitedResult && "resetInMs" in rateLimitedResult) {
104+
logger.warn("Rate limit hit!", {
105+
batch: batchCount,
106+
resetInMs: rateLimitedResult.resetInMs,
107+
totalRateLimited,
108+
});
109+
}
110+
111+
// Small delay between batches to not overwhelm
112+
await setTimeout(50);
113+
}
114+
115+
const duration = Date.now() - start;
116+
117+
logger.info("Stress test completed", {
118+
actualDurationMs: duration,
119+
totalAttempted,
120+
totalSuccess,
121+
totalRateLimited,
122+
totalOtherErrors,
123+
batchCount,
124+
});
125+
126+
return {
127+
config: {
128+
durationSeconds,
129+
batchSize,
130+
},
131+
results: {
132+
actualDurationMs: duration,
133+
totalAttempted,
134+
totalSuccess,
135+
totalRateLimited,
136+
totalOtherErrors,
137+
batchCount,
138+
hitRateLimit: totalRateLimited > 0,
139+
triggersPerSecond: Math.round((totalAttempted / duration) * 1000),
140+
},
141+
};
142+
},
143+
});
144+
145+
/**
146+
* Sustained load test - maintains a steady rate of triggers over time.
147+
* Useful for seeing how rate limits behave under sustained load.
148+
*
149+
* Note: Successfully triggered runs will continue executing after this test completes.
150+
*/
151+
export const sustainedLoadTest = task({
152+
id: "sustained-load-test",
153+
maxDuration: 300,
154+
run: async (payload: {
155+
/** Triggers per second to attempt (default: 100) */
156+
triggersPerSecond?: number;
157+
/** Duration in seconds (default: 20) */
158+
durationSeconds?: number;
159+
}) => {
160+
const triggersPerSecond = payload.triggersPerSecond ?? 100;
161+
const durationSeconds = payload.durationSeconds ?? 20;
162+
163+
const intervalMs = 1000 / triggersPerSecond;
164+
const totalTriggers = triggersPerSecond * durationSeconds;
165+
166+
logger.info("Starting sustained load test", {
167+
triggersPerSecond,
168+
durationSeconds,
169+
totalTriggers,
170+
intervalMs,
171+
});
172+
173+
const results: Array<{
174+
index: number;
175+
success: boolean;
176+
rateLimited: boolean;
177+
timestamp: number;
178+
}> = [];
179+
180+
const start = Date.now();
181+
let index = 0;
182+
183+
while (Date.now() - start < durationSeconds * 1000 && index < totalTriggers) {
184+
const triggerStart = Date.now();
185+
186+
try {
187+
await tasks.trigger<typeof noopTask>("noop-task", { index });
188+
results.push({
189+
index,
190+
success: true,
191+
rateLimited: false,
192+
timestamp: Date.now() - start,
193+
});
194+
} catch (error) {
195+
results.push({
196+
index,
197+
success: false,
198+
rateLimited: error instanceof RateLimitError,
199+
timestamp: Date.now() - start,
200+
});
201+
202+
if (error instanceof RateLimitError) {
203+
logger.warn("Rate limit hit during sustained load", {
204+
index,
205+
timestamp: Date.now() - start,
206+
resetInMs: error.millisecondsUntilReset,
207+
});
208+
}
209+
}
210+
211+
index++;
212+
213+
// Maintain the target rate
214+
const elapsed = Date.now() - triggerStart;
215+
const sleepTime = Math.max(0, intervalMs - elapsed);
216+
if (sleepTime > 0) {
217+
await setTimeout(sleepTime);
218+
}
219+
}
220+
221+
const duration = Date.now() - start;
222+
const successCount = results.filter((r) => r.success).length;
223+
const rateLimitedCount = results.filter((r) => r.rateLimited).length;
224+
225+
// Find when rate limiting started (if at all)
226+
const firstRateLimited = results.find((r) => r.rateLimited);
227+
228+
logger.info("Sustained load test completed", {
229+
actualDuration: duration,
230+
actualTriggers: results.length,
231+
successCount,
232+
rateLimitedCount,
233+
actualRate: Math.round((results.length / duration) * 1000),
234+
});
235+
236+
return {
237+
config: {
238+
targetTriggersPerSecond: triggersPerSecond,
239+
targetDurationSeconds: durationSeconds,
240+
},
241+
results: {
242+
actualDuration: duration,
243+
actualTriggers: results.length,
244+
successCount,
245+
rateLimitedCount,
246+
actualRate: Math.round((results.length / duration) * 1000),
247+
hitRateLimit: rateLimitedCount > 0,
248+
firstRateLimitedAt: firstRateLimited
249+
? {
250+
index: firstRateLimited.index,
251+
timestampMs: firstRateLimited.timestamp,
252+
}
253+
: null,
254+
},
255+
};
256+
},
257+
});

0 commit comments

Comments
 (0)