246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
// 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
|
|
import Redis from "ioredis";
|
|
|
|
// 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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
// 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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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 ({ redisOptions }) => {
|
|
const redis = new Redis(redisOptions);
|
|
|
|
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;
|
|
});
|
|
});
|