Skip to content

Commit 0898302

Browse files
committed
feat(webapp): share rate limit bucket across additional API keys per environment
1 parent c91b01d commit 0898302

4 files changed

Lines changed: 166 additions & 26 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Additional environment API keys now share a single rate limit bucket per environment instead of each key getting its own, so minting more keys no longer multiplies an environment's effective API rate limit. Root and legacy keys are unaffected. The bucket is keyed on the stable environment id resolved from the already-authenticated request, so no extra lookup is added.

apps/webapp/app/services/apiRateLimit.server.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { tryCatch } from "@trigger.dev/core/v3";
2+
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
23
import { env } from "~/env.server";
34
import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server";
45
import { authenticateAuthorizationHeader } from "./apiAuth.server";
@@ -40,13 +41,32 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
4041

4142
if (authenticatedEnv.type === "PUBLIC_JWT") {
4243
return {
43-
type: "fixedWindow",
44-
window: env.API_RATE_LIMIT_JWT_WINDOW,
45-
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
44+
config: {
45+
type: "fixedWindow",
46+
window: env.API_RATE_LIMIT_JWT_WINDOW,
47+
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
48+
},
4649
};
47-
} else {
48-
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
4950
}
51+
52+
// Additional API keys (`tr_*_sk_*`) share their environment's rate limit
53+
// bucket rather than each getting their own. Keying on the stable
54+
// environment id (not the secret key, which can rotate) keeps a single
55+
// bucket per environment no matter how many additional keys exist.
56+
// Root/legacy keys fall through to the default per-key (hashed header)
57+
// bucketing: they already map 1:1 to an environment.
58+
//
59+
// This reuses the already-authenticated environment above; the whole
60+
// override result is cached per key by the middleware's SWR cache, so no
61+
// separate lookup or Redis mapping is needed for the identifier.
62+
const apiKey = authenticatedEnv.apiKey;
63+
const identifier =
64+
apiKey && isAdditionalApiKey(apiKey) ? authenticatedEnv.environment.id : undefined;
65+
66+
return {
67+
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
68+
identifier,
69+
};
5070
},
5171
pathMatchers: [/^\/api/],
5272
// Allow /api/v1/tasks/:id/callback/:secret

apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,24 @@ export const RateLimiterConfig = z.discriminatedUnion("type", [
5252

5353
export type RateLimiterConfig = z.infer<typeof RateLimiterConfig>;
5454

55-
type LimitConfigOverrideFunction = (authorizationValue: string) => Promise<unknown>;
55+
/**
56+
* Result of an override lookup for a given Authorization header.
57+
*
58+
* - `config`: the rate limiter configuration to apply (bucket size). When
59+
* absent, the default limiter is used.
60+
* - `identifier`: the value to key the rate limit bucket on. When absent, the
61+
* hashed Authorization header is used (the legacy per-key behavior). Supply a
62+
* stable value (e.g. an environment id) so multiple credentials that should
63+
* share a bucket collapse onto one. Never a secret: it lands in Redis keys.
64+
*/
65+
type RateLimitOverride = {
66+
config?: unknown;
67+
identifier?: string;
68+
};
69+
70+
type LimitConfigOverrideFunction = (
71+
authorizationValue: string
72+
) => Promise<RateLimitOverride | undefined>;
5673

5774
type Options = {
5875
redis: RedisWithClusterOptions;
@@ -80,16 +97,22 @@ type Options = {
8097
};
8198
};
8299

83-
async function resolveLimitConfig(
100+
type ResolvedRateLimit = {
101+
config: RateLimiterConfig;
102+
// Bucket key to use, or undefined to fall back to the hashed Authorization header.
103+
identifier?: string;
104+
};
105+
106+
async function resolveRateLimit(
84107
authorizationValue: string,
85108
hashedAuthorizationValue: string,
86109
defaultLimiter: RateLimiterConfig,
87-
cache: UnkeyCache<{ limiter: RateLimiterConfig }>,
110+
cache: UnkeyCache<{ limiter: ResolvedRateLimit }>,
88111
logsEnabled: boolean,
89112
limiterConfigOverride?: LimitConfigOverrideFunction
90-
): Promise<RateLimiterConfig> {
113+
): Promise<ResolvedRateLimit> {
91114
if (!limiterConfigOverride) {
92-
return defaultLimiter;
115+
return { config: defaultLimiter };
93116
}
94117

95118
if (logsEnabled) {
@@ -110,18 +133,26 @@ async function resolveLimitConfig(
110133
});
111134
}
112135

113-
return defaultLimiter;
136+
return { config: defaultLimiter } satisfies ResolvedRateLimit;
114137
}
115138

116-
const parsedOverride = RateLimiterConfig.safeParse(override);
139+
// The identifier (if any) is trusted through even when the config falls back
140+
// to the default: bucketing and bucket size are independent concerns.
141+
const identifier = override.identifier;
142+
143+
if (!override.config) {
144+
return { config: defaultLimiter, identifier } satisfies ResolvedRateLimit;
145+
}
146+
147+
const parsedOverride = RateLimiterConfig.safeParse(override.config);
117148

118149
if (!parsedOverride.success) {
119150
logger.error("Error parsing rate limiter override", {
120151
override,
121152
errors: parsedOverride.error.errors,
122153
});
123154

124-
return defaultLimiter;
155+
return { config: defaultLimiter, identifier } satisfies ResolvedRateLimit;
125156
}
126157

127158
if (logsEnabled && parsedOverride.data) {
@@ -132,10 +163,10 @@ async function resolveLimitConfig(
132163
});
133164
}
134165

135-
return parsedOverride.data;
166+
return { config: parsedOverride.data, identifier } satisfies ResolvedRateLimit;
136167
});
137168

138-
return cacheResult.val ?? defaultLimiter;
169+
return cacheResult.val ?? { config: defaultLimiter };
139170
}
140171

141172
/**
@@ -176,7 +207,7 @@ export function authorizationRateLimitMiddleware({
176207

177208
// This cache holds the rate limit configuration for each org, so we don't have to fetch it every request
178209
const cache = createCache({
179-
limiter: new Namespace<RateLimiterConfig>(ctx, {
210+
limiter: new Namespace<ResolvedRateLimit>(ctx, {
180211
stores: [memory, redisCacheStore],
181212
fresh: limiterCache?.fresh ?? 30_000,
182213
stale: limiterCache?.stale ?? 60_000,
@@ -269,7 +300,7 @@ export function authorizationRateLimitMiddleware({
269300
hash.update(authorizationValue);
270301
const hashedAuthorizationValue = hash.digest("hex");
271302

272-
const limiterConfig = await resolveLimitConfig(
303+
const { config: limiterConfig, identifier } = await resolveRateLimit(
273304
authorizationValue,
274305
hashedAuthorizationValue,
275306
defaultLimiter,
@@ -278,6 +309,11 @@ export function authorizationRateLimitMiddleware({
278309
limiterConfigOverride
279310
);
280311

312+
// Bucket key: an override-supplied identifier (e.g. environment id, so all
313+
// additional API keys for an environment share one bucket) or, by default,
314+
// the hashed Authorization header (legacy per-key behavior).
315+
const rateLimitIdentifier = identifier ?? hashedAuthorizationValue;
316+
281317
const limiter = createLimiterFromConfig(limiterConfig);
282318

283319
const rateLimiter = new RateLimiter({
@@ -288,7 +324,7 @@ export function authorizationRateLimitMiddleware({
288324
logFailure: log.rejections,
289325
});
290326

291-
const { success, limit, reset, remaining } = await rateLimiter.limit(hashedAuthorizationValue);
327+
const { success, limit, reset, remaining } = await rateLimiter.limit(rateLimitIdentifier);
292328

293329
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
294330

apps/webapp/test/authorizationRateLimitMiddleware.test.ts

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,12 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware",
150150
limiterConfigOverride: async (authorizationValue) => {
151151
if (authorizationValue === "Bearer premium-token") {
152152
return {
153-
type: "tokenBucket",
154-
refillRate: 10,
155-
interval: "1m",
156-
maxTokens: 100,
153+
config: {
154+
type: "tokenBucket",
155+
refillRate: 10,
156+
interval: "1m",
157+
maxTokens: 100,
158+
},
157159
};
158160
}
159161
return undefined;
@@ -184,6 +186,80 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware",
184186
}
185187
);
186188

189+
redisTest(
190+
"should share a bucket across tokens that resolve to the same identifier",
191+
async ({ redisOptions }) => {
192+
const rateLimitMiddleware = authorizationRateLimitMiddleware({
193+
redis: { ...redisOptions, tlsDisabled: true },
194+
keyPrefix: "test-identifier",
195+
defaultLimiter: {
196+
type: "tokenBucket",
197+
refillRate: 1,
198+
interval: "1m",
199+
maxTokens: 1,
200+
},
201+
pathMatchers: [/^\/api/],
202+
// Both tokens map to the same environment identifier, so they should
203+
// consume from a single shared bucket rather than one bucket each.
204+
limiterConfigOverride: async () => ({ identifier: "env_shared" }),
205+
});
206+
207+
app.use(rateLimitMiddleware);
208+
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
209+
210+
// First token uses the single token in the shared bucket.
211+
const first = await request(app)
212+
.get("/api/test")
213+
.set("Authorization", "Bearer tr_prod_sk_aaaaaaaaaaaaaaaaaaaaaaaa");
214+
expect(first.status).toBe(200);
215+
216+
// A different token that resolves to the same identifier is limited,
217+
// because the bucket is shared rather than per-key.
218+
const second = await request(app)
219+
.get("/api/test")
220+
.set("Authorization", "Bearer tr_prod_sk_bbbbbbbbbbbbbbbbbbbbbbbb");
221+
expect(second.status).toBe(429);
222+
}
223+
);
224+
225+
redisTest(
226+
"should key per token when no identifier is supplied",
227+
async ({ redisOptions }) => {
228+
const rateLimitMiddleware = authorizationRateLimitMiddleware({
229+
redis: { ...redisOptions, tlsDisabled: true },
230+
keyPrefix: "test-no-identifier",
231+
defaultLimiter: {
232+
type: "tokenBucket",
233+
refillRate: 1,
234+
interval: "1m",
235+
maxTokens: 1,
236+
},
237+
pathMatchers: [/^\/api/],
238+
// Override supplies a config but no identifier: bucketing stays per-key
239+
// (hashed Authorization header), the legacy behavior.
240+
limiterConfigOverride: async () => ({
241+
config: { type: "tokenBucket", refillRate: 1, interval: "1m", maxTokens: 1 },
242+
}),
243+
});
244+
245+
app.use(rateLimitMiddleware);
246+
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
247+
248+
const first = await request(app).get("/api/test").set("Authorization", "Bearer token-a");
249+
expect(first.status).toBe(200);
250+
251+
// Same token is limited...
252+
const firstAgain = await request(app)
253+
.get("/api/test")
254+
.set("Authorization", "Bearer token-a");
255+
expect(firstAgain.status).toBe(429);
256+
257+
// ...but a different token gets its own bucket.
258+
const second = await request(app).get("/api/test").set("Authorization", "Bearer token-b");
259+
expect(second.status).toBe(200);
260+
}
261+
);
262+
187263
describe("Advanced Cases", () => {
188264
// 1. Test different rate limit configurations
189265
redisTest("should enforce fixed window rate limiting", async ({ redisOptions }) => {
@@ -375,10 +451,12 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware",
375451
configOverrideCalls++;
376452
if (authorizationValue === "Bearer premium-token") {
377453
return {
378-
type: "tokenBucket",
379-
refillRate: 10,
380-
interval: "1m",
381-
maxTokens: 100,
454+
config: {
455+
type: "tokenBucket",
456+
refillRate: 10,
457+
interval: "1m",
458+
maxTokens: 100,
459+
},
382460
};
383461
}
384462
return undefined;

0 commit comments

Comments
 (0)