Skip to content

Commit 10388e2

Browse files
committed
feat(webapp): share rate limit bucket across additional API keys per environment
remove comments fix(webapp): bucket restricted additional API keys by environment too feat(webapp): bucket root API keys per environment too fix(webapp): version rate-limit cache key and validate cached shape on read use new resolver for api keys fix(webapp): keep environment rate-limit buckets consistent Use environment identifiers when displaying remaining API capacity and ignore additional keys tied to deleted projects. Update .server-changes/additional-api-key-rate-limit-bucket.md Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> fix(webapp): keep public API keys on separate rate-limit buckets fix limits presenter for preview branches
1 parent 6872e2e commit 10388e2

8 files changed

Lines changed: 332 additions & 53 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+
API rate limits now apply per environment, so creating extra API keys no longer increases how many requests an environment can make.

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,89 @@ export async function findEnvironmentByApiKeyWithResolution(
301301
return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled);
302302
}
303303

304+
export type PrivateApiKeyRateLimitScope = {
305+
environmentId: string;
306+
apiRateLimiterConfig: unknown;
307+
};
308+
309+
export async function resolvePrivateApiKeyRateLimitScope(
310+
apiKey: string,
311+
tx: PrismaClientOrTransaction = $replica
312+
): Promise<PrivateApiKeyRateLimitScope | null> {
313+
const now = new Date();
314+
315+
if (isAdditionalApiKey(apiKey)) {
316+
const match = await tx.apiKey.findFirst({
317+
where: {
318+
keyHash: hashApiKey(apiKey),
319+
revokedAt: null,
320+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
321+
},
322+
select: {
323+
runtimeEnvironment: {
324+
select: {
325+
id: true,
326+
project: { select: { deletedAt: true } },
327+
organization: { select: { apiRateLimiterConfig: true } },
328+
},
329+
},
330+
},
331+
});
332+
333+
if (!match?.runtimeEnvironment || match.runtimeEnvironment.project.deletedAt) {
334+
return null;
335+
}
336+
337+
return {
338+
environmentId: match.runtimeEnvironment.id,
339+
apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig,
340+
};
341+
}
342+
343+
const environment = await tx.runtimeEnvironment.findFirst({
344+
where: { apiKey },
345+
select: {
346+
id: true,
347+
project: { select: { deletedAt: true } },
348+
organization: { select: { apiRateLimiterConfig: true } },
349+
},
350+
});
351+
352+
if (environment) {
353+
if (environment.project.deletedAt) {
354+
return null;
355+
}
356+
357+
return {
358+
environmentId: environment.id,
359+
apiRateLimiterConfig: environment.organization.apiRateLimiterConfig,
360+
};
361+
}
362+
363+
const revokedApiKey = await tx.revokedApiKey.findFirst({
364+
where: { apiKey, expiresAt: { gt: now } },
365+
select: {
366+
runtimeEnvironment: {
367+
select: {
368+
id: true,
369+
project: { select: { deletedAt: true } },
370+
organization: { select: { apiRateLimiterConfig: true } },
371+
},
372+
},
373+
},
374+
});
375+
376+
const revokedEnvironment = revokedApiKey?.runtimeEnvironment;
377+
if (!revokedEnvironment || revokedEnvironment.project.deletedAt) {
378+
return null;
379+
}
380+
381+
return {
382+
environmentId: revokedEnvironment.id,
383+
apiRateLimiterConfig: revokedEnvironment.organization.apiRateLimiterConfig,
384+
};
385+
}
386+
304387
/**
305388
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
306389
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).

apps/webapp/app/presenters/v3/LimitsPresenter.server.ts

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Ratelimit } from "@upstash/ratelimit";
22
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
3-
import { createHash } from "node:crypto";
43
import { env } from "~/env.server";
54
import { getCurrentPlan } from "~/services/platform.v3.server";
65
import {
@@ -90,13 +89,11 @@ export class LimitsPresenter extends BasePresenter {
9089
projectId,
9190
environmentId,
9291
environmentType,
93-
environmentApiKey,
9492
}: {
9593
organizationId: string;
9694
projectId: string;
9795
environmentId: string;
9896
environmentType: RuntimeEnvironmentType;
99-
environmentApiKey: string;
10097
}): Promise<LimitsResult> {
10198
// Get organization with all limit-related fields
10299
const organization = await this._replica.organization.findFirstOrThrow({
@@ -168,10 +165,21 @@ export class LimitsPresenter extends BasePresenter {
168165
where: { organizationId },
169166
});
170167

171-
// Get current rate limit tokens for this environment's API key
168+
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
169+
where: { id: environmentId },
170+
select: {
171+
id: true,
172+
parentEnvironmentId: true,
173+
maximumConcurrencyLimit: true,
174+
concurrencyLimitBurstFactor: true,
175+
},
176+
});
177+
const apiRateLimitEnvironmentId = runtimeEnv?.parentEnvironmentId ?? environmentId;
178+
179+
// Get current rate limit tokens for this environment's API bucket
172180
const apiRateLimitTokens = await getRateLimitRemainingTokens(
173181
"api",
174-
environmentApiKey,
182+
apiRateLimitEnvironmentId,
175183
apiRateLimitConfig
176184
);
177185
// Batch rate limiter uses environment ID directly (not hashed) with a different key prefix
@@ -181,15 +189,6 @@ export class LimitsPresenter extends BasePresenter {
181189
);
182190

183191
// Get current queue size for this environment
184-
// We need the runtime environment fields for the engine query
185-
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
186-
where: { id: environmentId },
187-
select: {
188-
id: true,
189-
maximumConcurrencyLimit: true,
190-
concurrencyLimitBurstFactor: true,
191-
},
192-
});
193192

194193
let currentQueueSize = 0;
195194
if (runtimeEnv) {
@@ -454,20 +453,14 @@ function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): {
454453

455454
/**
456455
* Query the current remaining tokens for a rate limiter using the Upstash getRemaining method.
457-
* This uses the same configuration and hashing logic as the rate limit middleware.
456+
* The API limiter uses the environment ID as the bucket identifier for private API keys.
458457
*/
459458
async function getRateLimitRemainingTokens(
460459
keyPrefix: string,
461-
apiKey: string,
460+
identifier: string,
462461
config: RateLimiterConfig
463462
): Promise<number | null> {
464463
try {
465-
// Hash the authorization header the same way the rate limiter does
466-
const authorizationValue = `Bearer ${apiKey}`;
467-
const hash = createHash("sha256");
468-
hash.update(authorizationValue);
469-
const hashedKey = hash.digest("hex");
470-
471464
// Create a Ratelimit instance with the same configuration
472465
const limiter = createLimiterFromConfig(config);
473466
const ratelimit = new Ratelimit({
@@ -478,9 +471,9 @@ async function getRateLimitRemainingTokens(
478471
prefix: `ratelimit:${keyPrefix}`,
479472
});
480473

481-
// Use the getRemaining method to get the current remaining tokens
474+
// Use the same identifier as the API rate-limit middleware.
482475
// getRemaining returns a Promise<number>
483-
const remaining = await ratelimit.getRemaining(hashedKey);
476+
const remaining = await ratelimit.getRemaining(identifier);
484477
return remaining;
485478
} catch (error) {
486479
logger.warn("Failed to get rate limit remaining tokens", {

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
7878
projectId: project.id,
7979
environmentId: environment.id,
8080
environmentType: environment.type,
81-
environmentApiKey: environment.apiKey,
8281
})
8382
);
8483

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { tryCatch } from "@trigger.dev/core/v3";
2+
import { trail } from "agentcrumbs"; // @crumbs
23
import { env } from "~/env.server";
4+
import { resolvePrivateApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server";
35
import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server";
46
import { authenticateAuthorizationHeader } from "./apiAuth.server";
57
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
68
import type { Duration } from "./rateLimiter.server";
79

810
const BATCH_STREAM_ITEMS_PATH = /^\/api\/v3\/batches\/([^/]+)\/items$/;
11+
const crumb = trail("webapp"); // @crumbs
912

1013
export const apiRateLimiter = authorizationRateLimitMiddleware({
1114
redis: {
@@ -29,6 +32,27 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
2932
maxItems: 1000,
3033
},
3134
limiterConfigOverride: async (authorizationValue) => {
35+
const rawApiKey = authorizationValue.replace(/^Bearer /, "");
36+
37+
if (rawApiKey.startsWith("tr_")) {
38+
const scope = await resolvePrivateApiKeyRateLimitScope(rawApiKey);
39+
40+
if (!scope) {
41+
return;
42+
}
43+
44+
// #region @crumbs
45+
crumb("resolved private API key rate limit scope", {
46+
environmentId: scope.environmentId,
47+
});
48+
// #endregion @crumbs
49+
50+
return {
51+
config: scope.apiRateLimiterConfig,
52+
identifier: scope.environmentId,
53+
};
54+
}
55+
3256
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
3357
allowPublicKey: true,
3458
allowJWT: true,
@@ -40,13 +64,19 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
4064

4165
if (authenticatedEnv.type === "PUBLIC_JWT") {
4266
return {
43-
type: "fixedWindow",
44-
window: env.API_RATE_LIMIT_JWT_WINDOW,
45-
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
67+
config: {
68+
type: "fixedWindow",
69+
window: env.API_RATE_LIMIT_JWT_WINDOW,
70+
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
71+
},
4672
};
47-
} else {
48-
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
4973
}
74+
75+
return {
76+
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
77+
// Public keys are browser-distributed, so keep them on per-key buckets.
78+
identifier: authenticatedEnv.type === "PRIVATE" ? authenticatedEnv.environment.id : undefined,
79+
};
5080
},
5181
pathMatchers: [/^\/api/],
5282
// Allow /api/v1/tasks/:id/callback/:secret

0 commit comments

Comments
 (0)