mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
perf(rate-limit): precompute bucket hash and client identifier (#2205)
This commit is contained in:
1 parent
15656bd5c8
commit
7c1c8b2749
2 files changed
+151
-4
No files matched your search
@@ -83,13 +83,12 @@ function getGlobalRateLimit(ctx: Context<HonoEnv>): number {
|
|||||||
return 50;
|
return 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBucket(bucket: string, ctx: Context<HonoEnv>): string {
|
function resolveBucket(bucket: string, clientId: string, ctx: Context<HonoEnv>): string {
|
||||||
let resolved = bucket;
|
let resolved = bucket;
|
||||||
const params = ctx.req.param();
|
const params = ctx.req.param();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
resolved = resolved.replace(`:${key}`, String(value));
|
resolved = resolved.replace(`:${key}`, String(value));
|
||||||
}
|
}
|
||||||
const clientId = getClientIdentifier(ctx);
|
|
||||||
return `${clientId}:${resolved}`;
|
return `${clientId}:${resolved}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +133,7 @@ async function revokeAuthenticatedSessionOnGlobalRateLimit(ctx: Context<HonoEnv>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): MiddlewareHandler<HonoEnv> {
|
export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): MiddlewareHandler<HonoEnv> {
|
||||||
|
const routeBucketHash = getBucketHash(routeConfig.bucket);
|
||||||
return createMiddleware<HonoEnv>(async (ctx, next) => {
|
return createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||||
if (!shouldEnforceRateLimits(ctx)) {
|
if (!shouldEnforceRateLimits(ctx)) {
|
||||||
await next();
|
await next();
|
||||||
@@ -152,7 +152,6 @@ export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): Middlewa
|
|||||||
const accountType = getAccountType(ctx);
|
const accountType = getAccountType(ctx);
|
||||||
const showHeaders = shouldShowHeadersOnSuccess(accountType);
|
const showHeaders = shouldShowHeadersOnSuccess(accountType);
|
||||||
const clientId = getClientIdentifier(ctx);
|
const clientId = getClientIdentifier(ctx);
|
||||||
const routeBucketHash = getBucketHash(routeConfig.bucket);
|
|
||||||
if (!routeConfig.config.exemptFromGlobal) {
|
if (!routeConfig.config.exemptFromGlobal) {
|
||||||
const globalLimit = getGlobalRateLimit(ctx);
|
const globalLimit = getGlobalRateLimit(ctx);
|
||||||
const globalResult = await rateLimitService.checkGlobalLimit(clientId, globalLimit);
|
const globalResult = await rateLimitService.checkGlobalLimit(clientId, globalLimit);
|
||||||
@@ -169,7 +168,7 @@ export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): Middlewa
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bucket = resolveBucket(routeConfig.bucket, ctx);
|
const bucket = resolveBucket(routeConfig.bucket, clientId, ctx);
|
||||||
const bucketConfigWithAlgorithm: BucketConfig = {
|
const bucketConfigWithAlgorithm: BucketConfig = {
|
||||||
...routeConfig.config,
|
...routeConfig.config,
|
||||||
algorithm: 'leaky_bucket',
|
algorithm: 'leaky_bucket',
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {createHash} from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
BucketConfig,
|
||||||
|
IRateLimitService,
|
||||||
|
RateLimitConfig,
|
||||||
|
RateLimitResult,
|
||||||
|
} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||||
|
import {type Context, Hono} from 'hono';
|
||||||
|
import {describe, expect, test} from 'vitest';
|
||||||
|
import type {HonoEnv} from '../../types/HonoEnv';
|
||||||
|
import {RateLimitMiddleware, type RouteRateLimitConfig} from '../RateLimitMiddleware';
|
||||||
|
|
||||||
|
const CLIENT_IP = '203.0.113.10';
|
||||||
|
const SWAPPED_CLIENT_IP = '198.51.100.7';
|
||||||
|
|
||||||
|
const WEBHOOK_READ: RouteRateLimitConfig = {
|
||||||
|
bucket: 'webhook:read::webhook_id',
|
||||||
|
config: {limit: 40, windowMs: 10000},
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEBHOOK_UPDATE: RouteRateLimitConfig = {
|
||||||
|
bucket: 'webhook:update::webhook_id',
|
||||||
|
config: {limit: 20, windowMs: 10000},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAllowedResult(limit: number): RateLimitResult {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
limit,
|
||||||
|
remaining: limit - 1,
|
||||||
|
resetTime: new Date(Date.now() + 10000),
|
||||||
|
resetAfterDecimal: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingRateLimitService implements IRateLimitService {
|
||||||
|
readonly globalIdentifiers: Array<string> = [];
|
||||||
|
readonly buckets: Array<string> = [];
|
||||||
|
onGlobalCheck: () => void = () => undefined;
|
||||||
|
|
||||||
|
async checkLimit(config: RateLimitConfig): Promise<RateLimitResult> {
|
||||||
|
return createAllowedResult(config.maxAttempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async peekLimit(config: RateLimitConfig): Promise<RateLimitResult> {
|
||||||
|
return createAllowedResult(config.maxAttempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkBucketLimit(bucket: string, config: BucketConfig): Promise<RateLimitResult> {
|
||||||
|
this.buckets.push(bucket);
|
||||||
|
return createAllowedResult(config.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkGlobalLimit(identifier: string, limit: number): Promise<RateLimitResult> {
|
||||||
|
this.globalIdentifiers.push(identifier);
|
||||||
|
this.onGlobalCheck();
|
||||||
|
return createAllowedResult(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetLimit(_identifier: string): Promise<void> {}
|
||||||
|
|
||||||
|
async clearLimitsByIdentifierPrefix(_identifierPrefix: string): Promise<number> {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
app: Hono<HonoEnv>;
|
||||||
|
service: RecordingRateLimitService;
|
||||||
|
getContext(): Context<HonoEnv>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHarness(routeConfig: RouteRateLimitConfig): Harness {
|
||||||
|
const service = new RecordingRateLimitService();
|
||||||
|
let context: Context<HonoEnv> | null = null;
|
||||||
|
const app = new Hono<HonoEnv>({strict: true});
|
||||||
|
app.use('*', async (ctx, next) => {
|
||||||
|
context = ctx;
|
||||||
|
ctx.set('rateLimitService', service);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.get('/webhooks/:webhook_id/:token', RateLimitMiddleware(routeConfig), (ctx) => ctx.text('ok'));
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
service,
|
||||||
|
getContext(): Context<HonoEnv> {
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('no request has run yet');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callRoute(harness: Harness, webhookId: string, clientIp = CLIENT_IP): Promise<Response> {
|
||||||
|
return await harness.app.request(`http://localhost/webhooks/${webhookId}/secret`, {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': clientIp,
|
||||||
|
'x-fluxer-test-enable-rate-limits': 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedBucketHash(bucket: string): string {
|
||||||
|
return createHash('sha256').update(bucket).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RateLimitMiddleware', () => {
|
||||||
|
test('reports the same bucket hash for every request to a route', async () => {
|
||||||
|
const harness = buildHarness(WEBHOOK_READ);
|
||||||
|
|
||||||
|
const first = await callRoute(harness, '111');
|
||||||
|
const second = await callRoute(harness, '222');
|
||||||
|
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(first.headers.get('X-RateLimit-Bucket')).toBe(expectedBucketHash(WEBHOOK_READ.bucket));
|
||||||
|
expect(second.headers.get('X-RateLimit-Bucket')).toBe(first.headers.get('X-RateLimit-Bucket'));
|
||||||
|
expect(harness.service.buckets).toEqual([`ip:${CLIENT_IP}:webhook:read:111`, `ip:${CLIENT_IP}:webhook:read:222`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gives routes with different buckets different bucket hashes', async () => {
|
||||||
|
const readHarness = buildHarness(WEBHOOK_READ);
|
||||||
|
const updateHarness = buildHarness(WEBHOOK_UPDATE);
|
||||||
|
|
||||||
|
const read = await callRoute(readHarness, '111');
|
||||||
|
const update = await callRoute(updateHarness, '111');
|
||||||
|
|
||||||
|
expect(read.headers.get('X-RateLimit-Bucket')).toBe(expectedBucketHash(WEBHOOK_READ.bucket));
|
||||||
|
expect(update.headers.get('X-RateLimit-Bucket')).toBe(expectedBucketHash(WEBHOOK_UPDATE.bucket));
|
||||||
|
expect(read.headers.get('X-RateLimit-Bucket')).not.toBe(update.headers.get('X-RateLimit-Bucket'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves the client identifier once and reuses it for the bucket key', async () => {
|
||||||
|
const harness = buildHarness(WEBHOOK_READ);
|
||||||
|
harness.service.onGlobalCheck = () => {
|
||||||
|
harness.getContext().req.raw.headers.set('x-forwarded-for', SWAPPED_CLIENT_IP);
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await callRoute(harness, '111');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(harness.service.globalIdentifiers).toEqual([`ip:${CLIENT_IP}`]);
|
||||||
|
expect(harness.service.buckets).toEqual([`ip:${CLIENT_IP}:webhook:read:111`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user