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
6 changes: 6 additions & 0 deletions .server-changes/additional-api-key-rate-limit-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

API rate limits now apply per environment, so creating extra API keys no longer increases how many requests an environment can make.
83 changes: 83 additions & 0 deletions apps/webapp/app/models/runtimeEnvironment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,89 @@ export async function findEnvironmentByApiKeyWithResolution(
return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled);
}

export type PrivateApiKeyRateLimitScope = {
environmentId: string;
apiRateLimiterConfig: unknown;
};

export async function resolvePrivateApiKeyRateLimitScope(
apiKey: string,
tx: PrismaClientOrTransaction = $replica
): Promise<PrivateApiKeyRateLimitScope | null> {
const now = new Date();

if (isAdditionalApiKey(apiKey)) {
const match = await tx.apiKey.findFirst({
where: {
keyHash: hashApiKey(apiKey),
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: {
runtimeEnvironment: {
select: {
id: true,
project: { select: { deletedAt: true } },
organization: { select: { apiRateLimiterConfig: true } },
},
},
},
});
Comment thread
carderne marked this conversation as resolved.

if (!match?.runtimeEnvironment || match.runtimeEnvironment.project.deletedAt) {
return null;
}

return {
environmentId: match.runtimeEnvironment.id,
apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig,
};
}

const environment = await tx.runtimeEnvironment.findFirst({
where: { apiKey },
select: {
id: true,
project: { select: { deletedAt: true } },
organization: { select: { apiRateLimiterConfig: true } },
},
});
Comment thread
carderne marked this conversation as resolved.

if (environment) {
if (environment.project.deletedAt) {
return null;
}

return {
environmentId: environment.id,
apiRateLimiterConfig: environment.organization.apiRateLimiterConfig,
};
}

const revokedApiKey = await tx.revokedApiKey.findFirst({
where: { apiKey, expiresAt: { gt: now } },
select: {
runtimeEnvironment: {
select: {
id: true,
project: { select: { deletedAt: true } },
organization: { select: { apiRateLimiterConfig: true } },
},
},
},
});

const revokedEnvironment = revokedApiKey?.runtimeEnvironment;
if (!revokedEnvironment || revokedEnvironment.project.deletedAt) {
return null;
}

return {
environmentId: revokedEnvironment.id,
apiRateLimiterConfig: revokedEnvironment.organization.apiRateLimiterConfig,
};
}

/**
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).
Expand Down
41 changes: 17 additions & 24 deletions apps/webapp/app/presenters/v3/LimitsPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Ratelimit } from "@upstash/ratelimit";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import {
Expand Down Expand Up @@ -90,13 +89,11 @@ export class LimitsPresenter extends BasePresenter {
projectId,
environmentId,
environmentType,
environmentApiKey,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
environmentApiKey: string;
}): Promise<LimitsResult> {
// Get organization with all limit-related fields
const organization = await this._replica.organization.findFirstOrThrow({
Expand Down Expand Up @@ -168,10 +165,21 @@ export class LimitsPresenter extends BasePresenter {
where: { organizationId },
});

// Get current rate limit tokens for this environment's API key
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
where: { id: environmentId },
select: {
id: true,
parentEnvironmentId: true,
maximumConcurrencyLimit: true,
concurrencyLimitBurstFactor: true,
},
});
const apiRateLimitEnvironmentId = runtimeEnv?.parentEnvironmentId ?? environmentId;

// Get current rate limit tokens for this environment's API bucket
const apiRateLimitTokens = await getRateLimitRemainingTokens(
"api",
environmentApiKey,
apiRateLimitEnvironmentId,
apiRateLimitConfig
);
// Batch rate limiter uses environment ID directly (not hashed) with a different key prefix
Expand All @@ -181,15 +189,6 @@ export class LimitsPresenter extends BasePresenter {
);

// Get current queue size for this environment
// We need the runtime environment fields for the engine query
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
where: { id: environmentId },
select: {
id: true,
maximumConcurrencyLimit: true,
concurrencyLimitBurstFactor: true,
},
});

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

/**
* Query the current remaining tokens for a rate limiter using the Upstash getRemaining method.
* This uses the same configuration and hashing logic as the rate limit middleware.
* The API limiter uses the environment ID as the bucket identifier for private API keys.
*/
async function getRateLimitRemainingTokens(
keyPrefix: string,
apiKey: string,
identifier: string,
config: RateLimiterConfig
): Promise<number | null> {
try {
// Hash the authorization header the same way the rate limiter does
const authorizationValue = `Bearer ${apiKey}`;
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedKey = hash.digest("hex");

// Create a Ratelimit instance with the same configuration
const limiter = createLimiterFromConfig(config);
const ratelimit = new Ratelimit({
Expand All @@ -478,9 +471,9 @@ async function getRateLimitRemainingTokens(
prefix: `ratelimit:${keyPrefix}`,
});

// Use the getRemaining method to get the current remaining tokens
// Use the same identifier as the API rate-limit middleware.
// getRemaining returns a Promise<number>
const remaining = await ratelimit.getRemaining(hashedKey);
const remaining = await ratelimit.getRemaining(identifier);
return remaining;
} catch (error) {
logger.warn("Failed to get rate limit remaining tokens", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
environmentApiKey: environment.apiKey,
})
);

Expand Down
40 changes: 35 additions & 5 deletions apps/webapp/app/services/apiRateLimit.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { tryCatch } from "@trigger.dev/core/v3";
import { trail } from "agentcrumbs"; // @crumbs
import { env } from "~/env.server";
import { resolvePrivateApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server";
import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server";
import { authenticateAuthorizationHeader } from "./apiAuth.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import type { Duration } from "./rateLimiter.server";

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

export const apiRateLimiter = authorizationRateLimitMiddleware({
redis: {
Expand All @@ -29,6 +32,27 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
maxItems: 1000,
},
limiterConfigOverride: async (authorizationValue) => {
const rawApiKey = authorizationValue.replace(/^Bearer /, "");

if (rawApiKey.startsWith("tr_")) {
const scope = await resolvePrivateApiKeyRateLimitScope(rawApiKey);

if (!scope) {
return;
}

// #region @crumbs
crumb("resolved private API key rate limit scope", {
environmentId: scope.environmentId,
});
// #endregion @crumbs

return {
config: scope.apiRateLimiterConfig,
identifier: scope.environmentId,
};
}

const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
allowPublicKey: true,
allowJWT: true,
Expand All @@ -40,13 +64,19 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({

if (authenticatedEnv.type === "PUBLIC_JWT") {
return {
type: "fixedWindow",
window: env.API_RATE_LIMIT_JWT_WINDOW,
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
config: {
type: "fixedWindow",
window: env.API_RATE_LIMIT_JWT_WINDOW,
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
},
};
} else {
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
}

return {
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
// Public keys are browser-distributed, so keep them on per-key buckets.
identifier: authenticatedEnv.type === "PRIVATE" ? authenticatedEnv.environment.id : undefined,
};
Comment thread
carderne marked this conversation as resolved.
Comment thread
carderne marked this conversation as resolved.
},
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
Expand Down
Loading
Loading