Rate limit alerts by channel for task run alerts using generic cell rate algo (#1679)

This commit is contained in:
Eric Allam
2025-02-07 13:36:37 +00:00
committed by GitHub
parent 5210d3a8bb
commit 9dfd6a5a0a
7 changed files with 528 additions and 21 deletions
+33
View File
@@ -308,6 +308,39 @@ const EnvironmentSchema = z.object({
ALERT_SMTP_SECURE: z.coerce.boolean().optional(),
ALERT_SMTP_USER: z.string().optional(),
ALERT_SMTP_PASSWORD: z.string().optional(),
ALERT_RATE_LIMITER_EMISSION_INTERVAL: z.coerce.number().int().default(2_500),
ALERT_RATE_LIMITER_BURST_TOLERANCE: z.coerce.number().int().default(10_000),
ALERT_RATE_LIMITER_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
ALERT_RATE_LIMITER_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
ALERT_RATE_LIMITER_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
ALERT_RATE_LIMITER_REDIS_PORT: z.coerce
.number()
.optional()
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
ALERT_RATE_LIMITER_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
ALERT_RATE_LIMITER_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
ALERT_RATE_LIMITER_REDIS_TLS_DISABLED: z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
@@ -0,0 +1,171 @@
import Redis, { Cluster } from "ioredis";
/**
* Options for configuring the RateLimiter.
*/
export interface GCRARateLimiterOptions {
/** An instance of ioredis. */
redis: Redis | Cluster;
/**
* A string prefix to namespace keys in Redis.
* Defaults to "ratelimit:".
*/
keyPrefix?: string;
/**
* The minimum interval between requests (the emission interval) in milliseconds.
* For example, 1000 ms for one request per second.
*/
emissionInterval: number;
/**
* The burst tolerance in milliseconds. This represents how much “credit” can be
* accumulated to allow short bursts beyond the average rate.
* For example, if you want to allow 3 requests in a burst with an emission interval of 1000 ms,
* you might set this to 3000.
*/
burstTolerance: number;
/**
* Expiration for the Redis key in milliseconds.
* Defaults to the larger of 60 seconds or (emissionInterval + burstTolerance).
*/
keyExpiration?: number;
}
/**
* The result of a rate limit check.
*/
export interface RateLimitResult {
/** Whether the request is allowed. */
allowed: boolean;
/**
* If not allowed, this is the number of milliseconds the caller should wait
* before retrying.
*/
retryAfter?: number;
}
/**
* A rate limiter using Redis and the Generic Cell Rate Algorithm (GCRA).
*
* The GCRA is implemented using a Lua script that runs atomically in Redis.
*
* When a request comes in, the algorithm:
* - Retrieves the current "Theoretical Arrival Time" (TAT) from Redis (or initializes it if missing).
* - If the current time is greater than or equal to the TAT, the request is allowed and the TAT is updated to now + emissionInterval.
* - Otherwise, if the current time plus the burst tolerance is at least the TAT, the request is allowed and the TAT is incremented.
* - If neither condition is met, the request is rejected and a Retry-After value is returned.
*/
export class GCRARateLimiter {
private redis: Redis | Cluster;
private keyPrefix: string;
private emissionInterval: number;
private burstTolerance: number;
private keyExpiration: number;
constructor(options: GCRARateLimiterOptions) {
this.redis = options.redis;
this.keyPrefix = options.keyPrefix || "gcra:ratelimit:";
this.emissionInterval = options.emissionInterval;
this.burstTolerance = options.burstTolerance;
// Default expiration: at least 60 seconds or the sum of emissionInterval and burstTolerance
this.keyExpiration =
options.keyExpiration || Math.max(60_000, this.emissionInterval + this.burstTolerance);
// Define a custom Redis command 'gcra' that implements the GCRA algorithm.
// Using defineCommand ensures the Lua script is loaded once and run atomically.
this.redis.defineCommand("gcra", {
numberOfKeys: 1,
lua: `
--[[
GCRA Lua script
KEYS[1] - The rate limit key (e.g. "ratelimit:<identifier>")
ARGV[1] - Current time in ms (number)
ARGV[2] - Emission interval in ms (number)
ARGV[3] - Burst tolerance in ms (number)
ARGV[4] - Key expiration in ms (number)
Returns: { allowedFlag, value }
allowedFlag: 1 if allowed, 0 if rate-limited.
value: 0 when allowed; if not allowed, the number of ms to wait.
]]--
local key = KEYS[1]
local now = tonumber(ARGV[1])
local emission_interval = tonumber(ARGV[2])
local burst_tolerance = tonumber(ARGV[3])
local expire = tonumber(ARGV[4])
-- Get the stored Theoretical Arrival Time (TAT) or default to 0.
local tat = tonumber(redis.call("GET", key) or 0)
if tat == 0 then
tat = now
end
local allowed, new_tat, retry_after
if now >= tat then
-- No delay: request is on schedule.
new_tat = now + emission_interval
allowed = true
elseif (now + burst_tolerance) >= tat then
-- Within burst capacity: allow request.
new_tat = tat + emission_interval
allowed = true
else
-- Request exceeds the allowed burst; calculate wait time.
allowed = false
retry_after = tat - (now + burst_tolerance)
end
if allowed then
redis.call("SET", key, new_tat, "PX", expire)
return {1, 0}
else
return {0, retry_after}
end
`,
});
}
/**
* Checks whether a request associated with the given identifier is allowed.
*
* @param identifier A unique string identifying the subject of rate limiting (e.g. user ID, IP address, or domain).
* @returns A promise that resolves to a RateLimitResult.
*
* @example
* const result = await rateLimiter.check('user:12345');
* if (!result.allowed) {
* // Tell the client to retry after result.retryAfter milliseconds.
* }
*/
async check(identifier: string): Promise<RateLimitResult> {
const key = `${this.keyPrefix}${identifier}`;
const now = Date.now();
try {
// Call the custom 'gcra' command.
// The script returns an array: [allowedFlag, value]
// - allowedFlag: 1 if allowed; 0 if rejected.
// - value: 0 when allowed; if rejected, the number of ms to wait before retrying.
// @ts-expect-error: The custom command is defined via defineCommand.
const result: [number, number] = await this.redis.gcra(
key,
now,
this.emissionInterval,
this.burstTolerance,
this.keyExpiration
);
const allowed = result[0] === 1;
if (allowed) {
return { allowed: true };
} else {
return { allowed: false, retryAfter: result[1] };
}
} catch (error) {
// In a production system you might log the error and either
// allow the request (fail open) or deny it (fail closed).
// Here we choose to propagate the error.
throw error;
}
}
}
@@ -0,0 +1,30 @@
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
import { GCRARateLimiter } from "./GCRARateLimiter.server";
import { singleton } from "~/utils/singleton";
import { logger } from "~/services/logger.server";
export const alertsRateLimiter = singleton("alertsRateLimiter", initializeAlertsRateLimiter);
function initializeAlertsRateLimiter() {
const redis = createRedisClient("alerts:ratelimiter", {
keyPrefix: "alerts:ratelimiter:",
host: env.ALERT_RATE_LIMITER_REDIS_HOST,
port: env.ALERT_RATE_LIMITER_REDIS_PORT,
username: env.ALERT_RATE_LIMITER_REDIS_USERNAME,
password: env.ALERT_RATE_LIMITER_REDIS_PASSWORD,
tlsDisabled: env.ALERT_RATE_LIMITER_REDIS_TLS_DISABLED === "true",
clusterMode: env.ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED === "1",
});
logger.debug(`🚦 Initializing alerts rate limiter at host ${env.ALERT_RATE_LIMITER_REDIS_HOST}`, {
emissionInterval: env.ALERT_RATE_LIMITER_EMISSION_INTERVAL,
burstTolerance: env.ALERT_RATE_LIMITER_BURST_TOLERANCE,
});
return new GCRARateLimiter({
redis,
emissionInterval: env.ALERT_RATE_LIMITER_EMISSION_INTERVAL,
burstTolerance: env.ALERT_RATE_LIMITER_BURST_TOLERANCE,
});
}
@@ -9,7 +9,7 @@ import {
import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3";
import assertNever from "assert-never";
import { subtle } from "crypto";
import { Prisma, PrismaClientOrTransaction, prisma } from "~/db.server";
import { Prisma, prisma, PrismaClientOrTransaction } from "~/db.server";
import { env } from "~/env.server";
import {
OrgIntegrationRepository,
@@ -25,10 +25,12 @@ import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"
import { sendAlertEmail } from "~/services/email.server";
import { logger } from "~/services/logger.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { workerQueue } from "~/services/worker.server";
import { BaseService } from "../baseService.server";
import { FINAL_ATTEMPT_STATUSES } from "~/v3/taskStatus";
import { commonWorker } from "~/v3/commonWorker.server";
import { FINAL_ATTEMPT_STATUSES } from "~/v3/taskStatus";
import { BaseService } from "../baseService.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { ProjectAlertType } from "@trigger.dev/database";
import { alertsRateLimiter } from "~/v3/alertsRateLimiter.server";
type FoundAlert = Prisma.Result<
typeof prisma.projectAlert,
@@ -1101,6 +1103,66 @@ export class DeliverAlertService extends BaseService {
availableAt: runAt,
});
}
static async createAndSendAlert(
{
channelId,
projectId,
environmentId,
alertType,
deploymentId,
taskRunId,
}: {
channelId: string;
projectId: string;
environmentId: string;
alertType: ProjectAlertType;
deploymentId?: string;
taskRunId?: string;
},
db: PrismaClientOrTransaction
) {
if (taskRunId) {
try {
const result = await alertsRateLimiter.check(channelId);
if (!result.allowed) {
logger.warn("[DeliverAlert] Rate limited", {
taskRunId,
environmentId,
alertType,
channelId,
result,
});
return;
}
} catch (error) {
logger.error("[DeliverAlert] Rate limiter error", {
taskRunId,
environmentId,
alertType,
channelId,
error,
});
}
}
const alert = await db.projectAlert.create({
data: {
friendlyId: generateFriendlyId("alert"),
channelId,
projectId,
environmentId,
status: "PENDING",
type: alertType,
workerDeploymentId: deploymentId,
taskRunId,
},
});
await DeliverAlertService.enqueue(alert.id);
}
}
function isWebAPIPlatformError(error: unknown): error is WebAPIPlatformError {
@@ -46,19 +46,16 @@ export class PerformDeploymentAlertsService extends BaseService {
deployment: WorkerDeployment,
alertType: ProjectAlertType
) {
const alert = await this._prisma.projectAlert.create({
data: {
friendlyId: generateFriendlyId("alert"),
await DeliverAlertService.createAndSendAlert(
{
channelId: alertChannel.id,
projectId: deployment.projectId,
environmentId: deployment.environmentId,
status: "PENDING",
type: alertType,
workerDeploymentId: deployment.id,
alertType,
deploymentId: deployment.id,
},
});
await DeliverAlertService.enqueue(alert.id);
this._prisma
);
}
static async enqueue(deploymentId: string, runAt?: Date) {
@@ -46,19 +46,16 @@ export class PerformTaskRunAlertsService extends BaseService {
}
async #createAndSendAlert(alertChannel: ProjectAlertChannel, run: FoundRun) {
const alert = await this._prisma.projectAlert.create({
data: {
friendlyId: generateFriendlyId("alert"),
await DeliverAlertService.createAndSendAlert(
{
channelId: alertChannel.id,
projectId: run.projectId,
environmentId: run.runtimeEnvironmentId,
status: "PENDING",
type: "TASK_RUN",
alertType: "TASK_RUN",
taskRunId: run.id,
},
});
await DeliverAlertService.enqueue(alert.id);
this._prisma
);
}
static async enqueue(runId: string, runAt?: Date) {
+217
View File
@@ -0,0 +1,217 @@
// GCRARateLimiter.test.ts
import { redisTest } from "@internal/testcontainers";
import { describe, expect, vi } from "vitest";
import { GCRARateLimiter } from "../app/v3/GCRARateLimiter.server.js"; // adjust the import as needed
// Extend the timeout to 30 seconds (as in your redis tests)
vi.setConfig({ testTimeout: 30_000 });
describe("GCRARateLimiter", () => {
redisTest("should allow a single request when under the rate limit", async ({ redis }) => {
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000, // 1 request per second on average
burstTolerance: 3000, // Allows a burst of 4 requests (3 * 1000 + 1)
keyPrefix: "test:ratelimit:",
});
const result = await limiter.check("user:1");
expect(result.allowed).toBe(true);
});
redisTest(
"should allow bursts up to the configured limit and then reject further requests",
async ({ redis }) => {
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000, // With an emission interval of 1000ms, burstTolerance of 3000ms allows 4 rapid requests.
keyPrefix: "test:ratelimit:",
});
// Call 4 times in rapid succession (all should be allowed)
const results = await Promise.all([
limiter.check("user:burst"),
limiter.check("user:burst"),
limiter.check("user:burst"),
limiter.check("user:burst"),
]);
results.forEach((result) => expect(result.allowed).toBe(true));
// The 5th call should be rejected.
const fifthResult = await limiter.check("user:burst");
expect(fifthResult.allowed).toBe(false);
expect(fifthResult.retryAfter).toBeGreaterThan(0);
}
);
redisTest("should allow a request after the required waiting period", async ({ redis }) => {
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// Exhaust burst capacity with 4 rapid calls.
await limiter.check("user:wait");
await limiter.check("user:wait");
await limiter.check("user:wait");
await limiter.check("user:wait");
// The 5th call should be rejected.
const rejection = await limiter.check("user:wait");
expect(rejection.allowed).toBe(false);
expect(rejection.retryAfter).toBeGreaterThan(0);
// Wait for the period specified in retryAfter (plus a small buffer)
await new Promise((resolve) => setTimeout(resolve, rejection.retryAfter! + 50));
// Now the next call should be allowed.
const allowedAfterWait = await limiter.check("user:wait");
expect(allowedAfterWait.allowed).toBe(true);
});
redisTest("should rate limit independently for different identifiers", async ({ redis }) => {
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// For "user:independent", exhaust burst capacity.
await limiter.check("user:independent");
await limiter.check("user:independent");
await limiter.check("user:independent");
await limiter.check("user:independent");
const rejected = await limiter.check("user:independent");
expect(rejected.allowed).toBe(false);
// A different identifier should start fresh.
const fresh = await limiter.check("user:different");
expect(fresh.allowed).toBe(true);
});
redisTest("should gradually reduce retryAfter with time", async ({ redis }) => {
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// Exhaust the burst capacity.
await limiter.check("user:gradual");
await limiter.check("user:gradual");
await limiter.check("user:gradual");
await limiter.check("user:gradual");
const firstRejection = await limiter.check("user:gradual");
expect(firstRejection.allowed).toBe(false);
const firstRetry = firstRejection.retryAfter!;
// Wait 500ms, then perform another check.
await new Promise((resolve) => setTimeout(resolve, 500));
const secondRejection = await limiter.check("user:gradual");
// It should still be rejected but with a smaller wait time.
expect(secondRejection.allowed).toBe(false);
const secondRetry = secondRejection.retryAfter!;
expect(secondRetry).toBeLessThan(firstRetry);
});
redisTest("should expire the key after the TTL", async ({ redis }) => {
// For this test, override keyExpiration to a short value.
const keyExpiration = 1500; // 1.5 seconds TTL
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 100,
burstTolerance: 300, // These values are arbitrary for this test.
keyPrefix: "test:expire:",
keyExpiration,
});
const identifier = "user:expire";
// Make a call to set the key.
const result = await limiter.check(identifier);
expect(result.allowed).toBe(true);
// Immediately verify the key exists.
const key = `test:expire:${identifier}`;
let stored = await redis.get(key);
expect(stored).not.toBeNull();
// Wait for longer than keyExpiration.
await new Promise((resolve) => setTimeout(resolve, keyExpiration + 200));
stored = await redis.get(key);
expect(stored).toBeNull();
});
redisTest("should not share state across different key prefixes", async ({ redis }) => {
const limiter1 = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit1:",
});
const limiter2 = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit2:",
});
// Exhaust the burst capacity for a given identifier in limiter1.
await limiter1.check("user:shared");
await limiter1.check("user:shared");
await limiter1.check("user:shared");
await limiter1.check("user:shared");
const rejection1 = await limiter1.check("user:shared");
expect(rejection1.allowed).toBe(false);
// With a different key prefix, the same identifier should be fresh.
const result2 = await limiter2.check("user:shared");
expect(result2.allowed).toBe(true);
});
redisTest("should increment TAT correctly on sequential allowed requests", async ({ redis }) => {
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// The first request should be allowed.
const r1 = await limiter.check("user:sequential");
expect(r1.allowed).toBe(true);
// Wait a bit longer than the emission interval.
await new Promise((resolve) => setTimeout(resolve, 1100));
const r2 = await limiter.check("user:sequential");
expect(r2.allowed).toBe(true);
});
redisTest("should throw an error if redis command fails", async ({ redis }) => {
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// Stub redis.gcra to simulate a failure.
// @ts-expect-error
const originalGcra = redis.gcra;
// @ts-ignore
redis.gcra = vi.fn(() => {
throw new Error("Simulated Redis error");
});
await expect(limiter.check("user:error")).rejects.toThrow("Simulated Redis error");
// Restore the original command.
// @ts-expect-error
redis.gcra = originalGcra;
});
});