refactor progress
This commit is contained in:
24
packages/rate_limit/package.json
Normal file
24
packages/rate_limit/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@fluxer/rate_limit",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"hono": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
24
packages/rate_limit/src/ICacheService.tsx
Normal file
24
packages/rate_limit/src/ICacheService.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface ICacheService {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
49
packages/rate_limit/src/IRateLimitService.tsx
Normal file
49
packages/rate_limit/src/IRateLimitService.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
resetTime: Date;
|
||||
retryAfter?: number;
|
||||
retryAfterDecimal?: number;
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
maxAttempts: number;
|
||||
windowMs: number;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface BucketConfig {
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
exemptFromGlobal?: boolean;
|
||||
}
|
||||
|
||||
export interface IRateLimitService {
|
||||
checkLimit(config: RateLimitConfig): Promise<RateLimitResult>;
|
||||
checkBucketLimit(bucket: string, config: BucketConfig): Promise<RateLimitResult>;
|
||||
checkGlobalLimit(identifier: string, limit: number): Promise<RateLimitResult>;
|
||||
resetLimit(identifier: string): Promise<void>;
|
||||
getRemainingAttempts(identifier: string, windowMs: number): Promise<number>;
|
||||
getResetTime(identifier: string, windowMs: number): Promise<Date>;
|
||||
}
|
||||
53
packages/rate_limit/src/InMemoryCacheService.tsx
Normal file
53
packages/rate_limit/src/InMemoryCacheService.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
expiresAt: number | null;
|
||||
}
|
||||
|
||||
export class InMemoryCacheService {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.expiresAt && entry.expiresAt < Date.now()) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const entry: CacheEntry<T> = {
|
||||
value,
|
||||
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
|
||||
};
|
||||
this.cache.set(key, entry as CacheEntry<unknown>);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
41
packages/rate_limit/src/KVRequiredError.tsx
Normal file
41
packages/rate_limit/src/KVRequiredError.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface KVRequiredErrorOptions {
|
||||
serviceName: string;
|
||||
|
||||
configPath: string;
|
||||
|
||||
fluxerServerHint?: string;
|
||||
}
|
||||
|
||||
export function throwKVRequiredError(options: KVRequiredErrorOptions): never {
|
||||
const {serviceName, configPath, fluxerServerHint} = options;
|
||||
|
||||
const message =
|
||||
`${serviceName} running in standalone mode requires KV-backed rate limiting. ` +
|
||||
`${configPath} is not set. ` +
|
||||
`Standalone services MUST have internal.kv configured for distributed rate limiting.`;
|
||||
|
||||
const hint = fluxerServerHint
|
||||
? `If running in fluxer_server mode, ensure ${fluxerServerHint}.`
|
||||
: `If running in fluxer_server mode, ensure setInjectedKVProvider() is called before service initialization.`;
|
||||
|
||||
throw new Error(`${message} ${hint}`);
|
||||
}
|
||||
114
packages/rate_limit/src/RateLimitService.tsx
Normal file
114
packages/rate_limit/src/RateLimitService.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ICacheService} from '@fluxer/rate_limit/src/ICacheService';
|
||||
import {InMemoryCacheService} from '@fluxer/rate_limit/src/InMemoryCacheService';
|
||||
import type {
|
||||
BucketConfig,
|
||||
IRateLimitService,
|
||||
RateLimitConfig,
|
||||
RateLimitResult,
|
||||
} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import {GcraRateLimiter} from '@fluxer/rate_limit/src/internal/GcraRateLimiter';
|
||||
import {RateLimitKeyFactory} from '@fluxer/rate_limit/src/internal/RateLimitKeyFactory';
|
||||
import {assertPositiveFiniteNumber} from '@fluxer/rate_limit/src/internal/RateLimitValidation';
|
||||
|
||||
export interface RateLimitServiceOptions {
|
||||
globalWindowMs?: number;
|
||||
getCurrentTimeMs?: () => number;
|
||||
}
|
||||
|
||||
export class RateLimitService implements IRateLimitService {
|
||||
private static readonly DEFAULT_GLOBAL_WINDOW_MS = 1000;
|
||||
private readonly gcraRateLimiter: GcraRateLimiter;
|
||||
private readonly keyFactory: RateLimitKeyFactory;
|
||||
private readonly globalWindowMs: number;
|
||||
|
||||
constructor(cacheService: ICacheService, options: RateLimitServiceOptions = {}) {
|
||||
this.gcraRateLimiter = new GcraRateLimiter(cacheService, options.getCurrentTimeMs);
|
||||
this.keyFactory = new RateLimitKeyFactory();
|
||||
this.globalWindowMs = options.globalWindowMs ?? RateLimitService.DEFAULT_GLOBAL_WINDOW_MS;
|
||||
assertPositiveFiniteNumber(this.globalWindowMs, 'globalWindowMs');
|
||||
}
|
||||
|
||||
async checkLimit(config: RateLimitConfig): Promise<RateLimitResult> {
|
||||
const key = this.keyFactory.getIdentifierKey(config.identifier);
|
||||
return this.gcraRateLimiter.checkLimit(key, {
|
||||
limit: config.maxAttempts,
|
||||
windowMs: config.windowMs,
|
||||
});
|
||||
}
|
||||
|
||||
async checkBucketLimit(bucket: string, config: BucketConfig): Promise<RateLimitResult> {
|
||||
const key = this.keyFactory.getBucketKey(bucket);
|
||||
return this.gcraRateLimiter.checkLimit(key, {
|
||||
limit: config.limit,
|
||||
windowMs: config.windowMs,
|
||||
});
|
||||
}
|
||||
|
||||
async checkGlobalLimit(identifier: string, limit: number): Promise<RateLimitResult> {
|
||||
const key = this.keyFactory.getGlobalKey(identifier);
|
||||
return this.gcraRateLimiter.checkLimit(
|
||||
key,
|
||||
{
|
||||
limit,
|
||||
windowMs: this.globalWindowMs,
|
||||
},
|
||||
{global: true},
|
||||
);
|
||||
}
|
||||
|
||||
async resetLimit(identifier: string): Promise<void> {
|
||||
const key = this.keyFactory.getIdentifierKey(identifier);
|
||||
await this.gcraRateLimiter.resetLimit(key);
|
||||
}
|
||||
|
||||
async getRemainingAttempts(identifier: string, _windowMs: number): Promise<number> {
|
||||
const key = this.keyFactory.getIdentifierKey(identifier);
|
||||
return this.gcraRateLimiter.getRemainingAttempts(key);
|
||||
}
|
||||
|
||||
async getResetTime(identifier: string, _windowMs: number): Promise<Date> {
|
||||
const key = this.keyFactory.getIdentifierKey(identifier);
|
||||
return this.gcraRateLimiter.getResetTime(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRateLimitService(
|
||||
cacheService: ICacheService | null,
|
||||
options: RateLimitServiceOptions = {},
|
||||
): RateLimitService | null {
|
||||
if (!cacheService) {
|
||||
return null;
|
||||
}
|
||||
return new RateLimitService(cacheService, options);
|
||||
}
|
||||
|
||||
export function createInMemoryRateLimitService(
|
||||
enabled: boolean,
|
||||
options: RateLimitServiceOptions = {},
|
||||
): RateLimitService | null {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheService = new InMemoryCacheService();
|
||||
return createRateLimitService(cacheService, options);
|
||||
}
|
||||
181
packages/rate_limit/src/__tests__/InMemoryCacheService.test.tsx
Normal file
181
packages/rate_limit/src/__tests__/InMemoryCacheService.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {InMemoryCacheService} from '@fluxer/rate_limit/src/InMemoryCacheService';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('InMemoryCacheService', () => {
|
||||
let cache: InMemoryCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new InMemoryCacheService();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return null for non-existent keys', async () => {
|
||||
const result = await cache.get('non-existent-key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the stored value for existing keys', async () => {
|
||||
await cache.set('test-key', {value: 42});
|
||||
const result = await cache.get<{value: number}>('test-key');
|
||||
expect(result).toEqual({value: 42});
|
||||
});
|
||||
|
||||
it('should return null for expired entries', async () => {
|
||||
await cache.set('expiring-key', {data: 'test'}, 5);
|
||||
vi.advanceTimersByTime(6000);
|
||||
const result = await cache.get('expiring-key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return value for non-expired entries', async () => {
|
||||
await cache.set('valid-key', {data: 'test'}, 10);
|
||||
vi.advanceTimersByTime(5000);
|
||||
const result = await cache.get<{data: string}>('valid-key');
|
||||
expect(result).toEqual({data: 'test'});
|
||||
});
|
||||
|
||||
it('should remove expired entries from cache on get', async () => {
|
||||
await cache.set('remove-test', 'value', 1);
|
||||
vi.advanceTimersByTime(2000);
|
||||
await cache.get('remove-test');
|
||||
vi.advanceTimersByTime(0);
|
||||
const secondGet = await cache.get('remove-test');
|
||||
expect(secondGet).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('should store primitive values', async () => {
|
||||
await cache.set('string-key', 'hello');
|
||||
await cache.set('number-key', 123);
|
||||
await cache.set('boolean-key', true);
|
||||
|
||||
expect(await cache.get('string-key')).toBe('hello');
|
||||
expect(await cache.get('number-key')).toBe(123);
|
||||
expect(await cache.get('boolean-key')).toBe(true);
|
||||
});
|
||||
|
||||
it('should store object values', async () => {
|
||||
const obj = {nested: {value: [1, 2, 3]}};
|
||||
await cache.set('object-key', obj);
|
||||
expect(await cache.get('object-key')).toEqual(obj);
|
||||
});
|
||||
|
||||
it('should store array values', async () => {
|
||||
const arr = [1, 'two', {three: 3}];
|
||||
await cache.set('array-key', arr);
|
||||
expect(await cache.get('array-key')).toEqual(arr);
|
||||
});
|
||||
|
||||
it('should overwrite existing values', async () => {
|
||||
await cache.set('overwrite-key', 'first');
|
||||
await cache.set('overwrite-key', 'second');
|
||||
expect(await cache.get('overwrite-key')).toBe('second');
|
||||
});
|
||||
|
||||
it('should store values without TTL indefinitely', async () => {
|
||||
await cache.set('no-ttl-key', 'persistent');
|
||||
vi.advanceTimersByTime(100000000);
|
||||
expect(await cache.get('no-ttl-key')).toBe('persistent');
|
||||
});
|
||||
|
||||
it('should calculate correct expiration time based on TTL', async () => {
|
||||
await cache.set('ttl-key', 'value', 60);
|
||||
vi.advanceTimersByTime(59999);
|
||||
expect(await cache.get('ttl-key')).toBe('value');
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(await cache.get('ttl-key')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should remove existing entries', async () => {
|
||||
await cache.set('delete-key', 'value');
|
||||
await cache.delete('delete-key');
|
||||
expect(await cache.get('delete-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not throw when deleting non-existent keys', async () => {
|
||||
await expect(cache.delete('non-existent')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should only delete the specified key', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
await cache.set('key2', 'value2');
|
||||
await cache.delete('key1');
|
||||
expect(await cache.get('key1')).toBeNull();
|
||||
expect(await cache.get('key2')).toBe('value2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string keys', async () => {
|
||||
await cache.set('', 'empty-key-value');
|
||||
expect(await cache.get('')).toBe('empty-key-value');
|
||||
});
|
||||
|
||||
it('should handle null values', async () => {
|
||||
await cache.set('null-value', null);
|
||||
expect(await cache.get('null-value')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined values', async () => {
|
||||
await cache.set('undefined-value', undefined);
|
||||
expect(await cache.get('undefined-value')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle zero TTL', async () => {
|
||||
await cache.set('zero-ttl', 'value', 0);
|
||||
expect(await cache.get('zero-ttl')).toBe('value');
|
||||
});
|
||||
|
||||
it('should handle very large TTL values', async () => {
|
||||
await cache.set('large-ttl', 'value', Number.MAX_SAFE_INTEGER);
|
||||
expect(await cache.get('large-ttl')).toBe('value');
|
||||
});
|
||||
|
||||
it('should handle special characters in keys', async () => {
|
||||
const specialKey = 'key:with:colons/and/slashes?and=query¶ms';
|
||||
await cache.set(specialKey, 'special');
|
||||
expect(await cache.get(specialKey)).toBe('special');
|
||||
});
|
||||
|
||||
it('should handle concurrent operations', async () => {
|
||||
const operations = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
operations.push(cache.set(`concurrent-${i}`, i));
|
||||
}
|
||||
await Promise.all(operations);
|
||||
|
||||
const results = await Promise.all(Array.from({length: 100}, (_, i) => cache.get(`concurrent-${i}`)));
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(results[i]).toBe(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
112
packages/rate_limit/src/__tests__/KVRequiredError.test.tsx
Normal file
112
packages/rate_limit/src/__tests__/KVRequiredError.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {throwKVRequiredError} from '@fluxer/rate_limit/src/KVRequiredError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('throwKVRequiredError', () => {
|
||||
it('should throw an error with the service name', () => {
|
||||
expect(() =>
|
||||
throwKVRequiredError({
|
||||
serviceName: 'fluxer_api',
|
||||
configPath: 'internal.kv.url',
|
||||
}),
|
||||
).toThrow('fluxer_api running in standalone mode requires KV-backed rate limiting');
|
||||
});
|
||||
|
||||
it('should include the config path in the error message', () => {
|
||||
expect(() =>
|
||||
throwKVRequiredError({
|
||||
serviceName: 'TestService',
|
||||
configPath: 'config.kv.connection_string',
|
||||
}),
|
||||
).toThrow('config.kv.connection_string is not set');
|
||||
});
|
||||
|
||||
it('should include the default hint when fluxerServerHint is not provided', () => {
|
||||
expect(() =>
|
||||
throwKVRequiredError({
|
||||
serviceName: 'TestService',
|
||||
configPath: 'internal.kv',
|
||||
}),
|
||||
).toThrow(
|
||||
'If running in fluxer_server mode, ensure setInjectedKVProvider() is called before service initialization',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include custom hint when fluxerServerHint is provided', () => {
|
||||
expect(() =>
|
||||
throwKVRequiredError({
|
||||
serviceName: 'fluxer_gateway',
|
||||
configPath: 'internal.kv.url',
|
||||
fluxerServerHint: 'the gateway is initialized after KV setup',
|
||||
}),
|
||||
).toThrow('If running in fluxer_server mode, ensure the gateway is initialized after KV setup');
|
||||
});
|
||||
|
||||
it('should construct complete error message with all parts', () => {
|
||||
let errorMessage = '';
|
||||
try {
|
||||
throwKVRequiredError({
|
||||
serviceName: 'fluxer_admin',
|
||||
configPath: 'admin.kv.endpoint',
|
||||
fluxerServerHint: 'KV is properly configured',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
expect(errorMessage).toContain('fluxer_admin running in standalone mode requires KV-backed rate limiting');
|
||||
expect(errorMessage).toContain('admin.kv.endpoint is not set');
|
||||
expect(errorMessage).toContain(
|
||||
'Standalone services MUST have internal.kv configured for distributed rate limiting',
|
||||
);
|
||||
expect(errorMessage).toContain('If running in fluxer_server mode, ensure KV is properly configured');
|
||||
});
|
||||
|
||||
it('should always throw (never return)', () => {
|
||||
const fn = () =>
|
||||
throwKVRequiredError({
|
||||
serviceName: 'test',
|
||||
configPath: 'test.path',
|
||||
});
|
||||
|
||||
expect(fn).toThrow(Error);
|
||||
});
|
||||
|
||||
it('should handle empty service name', () => {
|
||||
expect(() =>
|
||||
throwKVRequiredError({
|
||||
serviceName: '',
|
||||
configPath: 'internal.kv',
|
||||
}),
|
||||
).toThrow('running in standalone mode requires KV-backed rate limiting');
|
||||
});
|
||||
|
||||
it('should handle empty config path', () => {
|
||||
expect(() =>
|
||||
throwKVRequiredError({
|
||||
serviceName: 'TestService',
|
||||
configPath: '',
|
||||
}),
|
||||
).toThrow('is not set');
|
||||
});
|
||||
});
|
||||
440
packages/rate_limit/src/__tests__/RateLimitMiddleware.test.tsx
Normal file
440
packages/rate_limit/src/__tests__/RateLimitMiddleware.test.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {InMemoryCacheService} from '@fluxer/rate_limit/src/InMemoryCacheService';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import {
|
||||
createRateLimitMiddleware,
|
||||
type RateLimitMiddlewareConfig,
|
||||
type RateLimitMiddlewareOptions,
|
||||
setRateLimitHeaders,
|
||||
} from '@fluxer/rate_limit/src/middleware/RateLimitMiddleware';
|
||||
import {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('RateLimitMiddleware', () => {
|
||||
let app: Hono;
|
||||
let rateLimitService: IRateLimitService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new InMemoryCacheService();
|
||||
rateLimitService = new RateLimitService(cache);
|
||||
app = new Hono();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function setupApp(config: RateLimitMiddlewareConfig, options?: Partial<RateLimitMiddlewareOptions>) {
|
||||
const middleware = createRateLimitMiddleware({
|
||||
rateLimitService,
|
||||
config,
|
||||
getClientIdentifier: (req) => req.headers.get('X-Forwarded-For') ?? '127.0.0.1',
|
||||
...options,
|
||||
});
|
||||
app.use('*', middleware);
|
||||
app.get('/test', (c) => c.json({message: 'success'}));
|
||||
app.get('/_health', (c) => c.json({status: 'ok'}));
|
||||
app.post('/api/messages', (c) => c.json({created: true}));
|
||||
}
|
||||
|
||||
describe('basic rate limiting', () => {
|
||||
it('should allow requests within the limit', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 5,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
const response = await app.request('/test', {
|
||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {message: string};
|
||||
expect(body.message).toBe('success');
|
||||
});
|
||||
|
||||
it('should block requests exceeding the limit', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 3,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await app.request('/test', {
|
||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||
});
|
||||
}
|
||||
|
||||
const response = await app.request('/test', {
|
||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
const body = (await response.json()) as {error: string; retryAfter: number};
|
||||
expect(body.error).toBe('Rate limit exceeded');
|
||||
expect(body.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should track different clients separately', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 2,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
const blockedResponse = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
|
||||
const otherClientResponse = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.2'}});
|
||||
|
||||
expect(blockedResponse.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
expect(otherClientResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limit headers', () => {
|
||||
it('should include X-RateLimit-Limit header', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 10,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
const response = await app.request('/test', {
|
||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||
});
|
||||
|
||||
expect(response.headers.get('X-RateLimit-Limit')).toBe('10');
|
||||
});
|
||||
|
||||
it('should include X-RateLimit-Remaining header', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 10,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
const response1 = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
const response2 = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
|
||||
expect(response1.headers.get('X-RateLimit-Remaining')).toBe('9');
|
||||
expect(response2.headers.get('X-RateLimit-Remaining')).toBe('8');
|
||||
});
|
||||
|
||||
it('should include X-RateLimit-Reset header', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 10,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
const response = await app.request('/test', {
|
||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||
});
|
||||
|
||||
const resetHeader = response.headers.get('X-RateLimit-Reset');
|
||||
expect(resetHeader).not.toBeNull();
|
||||
const resetTime = parseInt(resetHeader!, 10);
|
||||
expect(resetTime).toBeGreaterThan(Math.floor(Date.now() / 1000));
|
||||
});
|
||||
|
||||
it('should include Retry-After header when rate limited', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
const response = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
|
||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
expect(retryAfter).not.toBeNull();
|
||||
expect(parseInt(retryAfter!, 10)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipPaths configuration', () => {
|
||||
it('should skip rate limiting for exact path matches', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
skipPaths: ['/_health'],
|
||||
});
|
||||
|
||||
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
|
||||
const healthResponse1 = await app.request('/_health', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
const healthResponse2 = await app.request('/_health', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
const healthResponse3 = await app.request('/_health', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
|
||||
expect(healthResponse1.status).toBe(200);
|
||||
expect(healthResponse2.status).toBe(200);
|
||||
expect(healthResponse3.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should skip rate limiting for prefix path matches', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
skipPaths: ['/api/'],
|
||||
});
|
||||
|
||||
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
const blockedTest = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
|
||||
const apiResponse = await app.request('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||
});
|
||||
|
||||
expect(blockedTest.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
expect(apiResponse.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should still rate limit non-skipped paths', async () => {
|
||||
setupApp({
|
||||
enabled: true,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
skipPaths: ['/_health'],
|
||||
});
|
||||
|
||||
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
const response = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
|
||||
|
||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled rate limiting', () => {
|
||||
it('should bypass rate limiting when disabled', async () => {
|
||||
setupApp({
|
||||
enabled: false,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
const responses = await Promise.all([
|
||||
app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}}),
|
||||
app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}}),
|
||||
app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}}),
|
||||
]);
|
||||
|
||||
expect(responses.every((r) => r.status === 200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('null rate limit service', () => {
|
||||
it('should bypass rate limiting when service is null', async () => {
|
||||
const middleware = createRateLimitMiddleware({
|
||||
rateLimitService: null,
|
||||
config: {
|
||||
enabled: true,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
},
|
||||
getClientIdentifier: () => '127.0.0.1',
|
||||
});
|
||||
|
||||
const testApp = new Hono();
|
||||
testApp.use('*', middleware);
|
||||
testApp.get('/test', (c) => c.json({ok: true}));
|
||||
|
||||
const responses = await Promise.all([
|
||||
testApp.request('/test'),
|
||||
testApp.request('/test'),
|
||||
testApp.request('/test'),
|
||||
]);
|
||||
|
||||
expect(responses.every((r) => r.status === 200)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support lazy service provider function', async () => {
|
||||
let callCount = 0;
|
||||
const serviceProvider = () => {
|
||||
callCount++;
|
||||
return rateLimitService;
|
||||
};
|
||||
|
||||
const middleware = createRateLimitMiddleware({
|
||||
rateLimitService: serviceProvider,
|
||||
config: {
|
||||
enabled: true,
|
||||
limit: 5,
|
||||
windowMs: 60000,
|
||||
},
|
||||
getClientIdentifier: () => '127.0.0.1',
|
||||
});
|
||||
|
||||
const testApp = new Hono();
|
||||
testApp.use('*', middleware);
|
||||
testApp.get('/test', (c) => c.json({ok: true}));
|
||||
|
||||
await testApp.request('/test');
|
||||
await testApp.request('/test');
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle service provider returning null', async () => {
|
||||
const middleware = createRateLimitMiddleware({
|
||||
rateLimitService: () => null,
|
||||
config: {
|
||||
enabled: true,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
},
|
||||
getClientIdentifier: () => '127.0.0.1',
|
||||
});
|
||||
|
||||
const testApp = new Hono();
|
||||
testApp.use('*', middleware);
|
||||
testApp.get('/test', (c) => c.json({ok: true}));
|
||||
|
||||
const responses = await Promise.all([testApp.request('/test'), testApp.request('/test')]);
|
||||
|
||||
expect(responses.every((r) => r.status === 200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom bucket naming', () => {
|
||||
it('should use custom bucket name from getBucketName', async () => {
|
||||
const middleware = createRateLimitMiddleware({
|
||||
rateLimitService,
|
||||
config: {
|
||||
enabled: true,
|
||||
limit: 2,
|
||||
windowMs: 60000,
|
||||
},
|
||||
getClientIdentifier: (req) => req.headers.get('X-Forwarded-For') ?? '127.0.0.1',
|
||||
getBucketName: (identifier, c) => `${identifier}:${c.req.method}:${c.req.path}`,
|
||||
});
|
||||
|
||||
const testApp = new Hono();
|
||||
testApp.use('*', middleware);
|
||||
testApp.get('/resource', (c) => c.json({ok: true}));
|
||||
testApp.post('/resource', (c) => c.json({created: true}));
|
||||
|
||||
await testApp.request('/resource', {method: 'GET', headers: {'X-Forwarded-For': '1.1.1.1'}});
|
||||
await testApp.request('/resource', {method: 'GET', headers: {'X-Forwarded-For': '1.1.1.1'}});
|
||||
const getBlocked = await testApp.request('/resource', {method: 'GET', headers: {'X-Forwarded-For': '1.1.1.1'}});
|
||||
|
||||
const postAllowed = await testApp.request('/resource', {method: 'POST', headers: {'X-Forwarded-For': '1.1.1.1'}});
|
||||
|
||||
expect(getBlocked.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
expect(postAllowed.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom onRateLimitExceeded handler', () => {
|
||||
it('should use custom response when rate limit exceeded', async () => {
|
||||
const middleware = createRateLimitMiddleware({
|
||||
rateLimitService,
|
||||
config: {
|
||||
enabled: true,
|
||||
limit: 1,
|
||||
windowMs: 60000,
|
||||
},
|
||||
getClientIdentifier: () => '127.0.0.1',
|
||||
onRateLimitExceeded: (c, retryAfter) => {
|
||||
return c.json(
|
||||
{
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Slow down!',
|
||||
retry_after: retryAfter,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const testApp = new Hono();
|
||||
testApp.use('*', middleware);
|
||||
testApp.get('/test', (c) => c.json({ok: true}));
|
||||
|
||||
await testApp.request('/test');
|
||||
const response = await testApp.request('/test');
|
||||
|
||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
const body = (await response.json()) as {code: string; message: string; retry_after: number};
|
||||
expect(body.code).toBe('RATE_LIMITED');
|
||||
expect(body.message).toBe('Slow down!');
|
||||
expect(body.retry_after).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRateLimitHeaders', () => {
|
||||
it('should set all required headers', () => {
|
||||
const headers: Record<string, string> = {};
|
||||
const ctx = {
|
||||
header: (name: string, value: string) => {
|
||||
headers[name] = value;
|
||||
},
|
||||
};
|
||||
|
||||
const resetTime = new Date(Date.now() + 60000);
|
||||
setRateLimitHeaders(ctx, 100, 75, resetTime);
|
||||
|
||||
expect(headers['X-RateLimit-Limit']).toBe('100');
|
||||
expect(headers['X-RateLimit-Remaining']).toBe('75');
|
||||
expect(headers['X-RateLimit-Reset']).toBe(Math.floor(resetTime.getTime() / 1000).toString());
|
||||
});
|
||||
|
||||
it('should handle zero remaining', () => {
|
||||
const headers: Record<string, string> = {};
|
||||
const ctx = {
|
||||
header: (name: string, value: string) => {
|
||||
headers[name] = value;
|
||||
},
|
||||
};
|
||||
|
||||
const resetTime = new Date(Date.now() + 30000);
|
||||
setRateLimitHeaders(ctx, 10, 0, resetTime);
|
||||
|
||||
expect(headers['X-RateLimit-Remaining']).toBe('0');
|
||||
});
|
||||
|
||||
it('should convert reset time to unix seconds', () => {
|
||||
const headers: Record<string, string> = {};
|
||||
const ctx = {
|
||||
header: (name: string, value: string) => {
|
||||
headers[name] = value;
|
||||
},
|
||||
};
|
||||
|
||||
const resetTimeMs = 1700000000000;
|
||||
const resetTime = new Date(resetTimeMs);
|
||||
setRateLimitHeaders(ctx, 50, 25, resetTime);
|
||||
|
||||
expect(headers['X-RateLimit-Reset']).toBe('1700000000');
|
||||
});
|
||||
});
|
||||
623
packages/rate_limit/src/__tests__/RateLimitService.test.tsx
Normal file
623
packages/rate_limit/src/__tests__/RateLimitService.test.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {InMemoryCacheService} from '@fluxer/rate_limit/src/InMemoryCacheService';
|
||||
import {createInMemoryRateLimitService, RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('RateLimitService (GCRA)', () => {
|
||||
let cache: InMemoryCacheService;
|
||||
let service: RateLimitService;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new InMemoryCacheService();
|
||||
service = new RateLimitService(cache);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-27T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('checkLimit - basic behavior', () => {
|
||||
it('should allow first request', async () => {
|
||||
const config = {
|
||||
identifier: 'user:123',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.limit).toBe(5);
|
||||
expect(result.remaining).toBe(4);
|
||||
expect(result.resetTime).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should decrement remaining with each request', async () => {
|
||||
const config = {
|
||||
identifier: 'user:123',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
const r1 = await service.checkLimit(config);
|
||||
const r2 = await service.checkLimit(config);
|
||||
const r3 = await service.checkLimit(config);
|
||||
|
||||
expect(r1.remaining).toBe(4);
|
||||
expect(r2.remaining).toBe(3);
|
||||
expect(r3.remaining).toBe(2);
|
||||
});
|
||||
|
||||
it('should allow burst up to limit', async () => {
|
||||
const config = {
|
||||
identifier: 'user:burst',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(await service.checkLimit(config));
|
||||
}
|
||||
|
||||
expect(results.every((r) => r.allowed)).toBe(true);
|
||||
expect(results[4].remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('should block when burst exhausted', async () => {
|
||||
const config = {
|
||||
identifier: 'user:blocked',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('should isolate different identifiers', async () => {
|
||||
const config1 = {identifier: 'user:1', maxAttempts: 2, windowMs: 5000};
|
||||
const config2 = {identifier: 'user:2', maxAttempts: 2, windowMs: 5000};
|
||||
|
||||
await service.checkLimit(config1);
|
||||
await service.checkLimit(config1);
|
||||
const user1Blocked = await service.checkLimit(config1);
|
||||
|
||||
const user2Allowed = await service.checkLimit(config2);
|
||||
|
||||
expect(user1Blocked.allowed).toBe(false);
|
||||
expect(user2Allowed.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLimit - GCRA token refill', () => {
|
||||
it('should allow request after one emission interval', async () => {
|
||||
const config = {
|
||||
identifier: 'user:refill',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const allowed = await service.checkLimit(config);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should refill tokens gradually over time', async () => {
|
||||
const config = {
|
||||
identifier: 'user:gradual',
|
||||
maxAttempts: 10,
|
||||
windowMs: 10000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
const r1 = await service.checkLimit(config);
|
||||
const r2 = await service.checkLimit(config);
|
||||
const r3 = await service.checkLimit(config);
|
||||
const r4 = await service.checkLimit(config);
|
||||
|
||||
expect(r1.allowed).toBe(true);
|
||||
expect(r2.allowed).toBe(true);
|
||||
expect(r3.allowed).toBe(true);
|
||||
expect(r4.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow sustained traffic at steady rate', async () => {
|
||||
const config = {
|
||||
identifier: 'user:steady',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
vi.advanceTimersByTime(1000);
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fully reset after window duration with no requests', async () => {
|
||||
const config = {
|
||||
identifier: 'user:full-reset',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(5001);
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(await service.checkLimit(config));
|
||||
}
|
||||
|
||||
expect(results.every((r) => r.allowed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry-after correctness', () => {
|
||||
it('should return valid retry-after when blocked', async () => {
|
||||
const config = {
|
||||
identifier: 'retry:basic',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfter).toBe(1);
|
||||
expect(blocked.retryAfterDecimal).toBeCloseTo(1, 1);
|
||||
expect(Number.isFinite(blocked.retryAfter)).toBe(true);
|
||||
expect(Number.isNaN(blocked.retryAfter)).toBe(false);
|
||||
});
|
||||
|
||||
it('should have retry-after that decreases as time passes', async () => {
|
||||
const config = {
|
||||
identifier: 'retry:decrease',
|
||||
maxAttempts: 2,
|
||||
windowMs: 10000,
|
||||
};
|
||||
|
||||
await service.checkLimit(config);
|
||||
await service.checkLimit(config);
|
||||
|
||||
const blocked1 = await service.checkLimit(config);
|
||||
expect(blocked1.retryAfter).toBe(5);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
const blocked2 = await service.checkLimit(config);
|
||||
expect(blocked2.retryAfter).toBe(3);
|
||||
});
|
||||
|
||||
it('should allow request after waiting retry-after duration', async () => {
|
||||
const config = {
|
||||
identifier: 'retry:wait',
|
||||
maxAttempts: 3,
|
||||
windowMs: 6000,
|
||||
};
|
||||
|
||||
await service.checkLimit(config);
|
||||
await service.checkLimit(config);
|
||||
await service.checkLimit(config);
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(blocked.retryAfter! * 1000);
|
||||
|
||||
const allowed = await service.checkLimit(config);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should have minimum retry-after of 1 second', async () => {
|
||||
const config = {
|
||||
identifier: 'retry:minimum',
|
||||
maxAttempts: 10,
|
||||
windowMs: 1000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
expect(blocked.retryAfter).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset time correctness', () => {
|
||||
it('should return reset time in the future when allowed', async () => {
|
||||
const config = {
|
||||
identifier: 'reset:allowed',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
const now = Date.now();
|
||||
|
||||
expect(result.resetTime.getTime()).toBeGreaterThan(now);
|
||||
});
|
||||
|
||||
it('should return valid reset time when blocked', async () => {
|
||||
const config = {
|
||||
identifier: 'reset:blocked',
|
||||
maxAttempts: 2,
|
||||
windowMs: 10000,
|
||||
};
|
||||
|
||||
await service.checkLimit(config);
|
||||
await service.checkLimit(config);
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
const now = Date.now();
|
||||
|
||||
expect(blocked.resetTime.getTime()).toBeGreaterThan(now);
|
||||
expect(Number.isFinite(blocked.resetTime.getTime())).toBe(true);
|
||||
});
|
||||
|
||||
it('should produce valid X-RateLimit-Reset timestamp', async () => {
|
||||
const config = {
|
||||
identifier: 'reset:header',
|
||||
maxAttempts: 3,
|
||||
windowMs: 30000,
|
||||
};
|
||||
|
||||
await service.checkLimit(config);
|
||||
const result = await service.checkLimit(config);
|
||||
|
||||
const resetTimestamp = Math.floor(result.resetTime.getTime() / 1000);
|
||||
const nowTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
expect(resetTimestamp).toBeGreaterThan(nowTimestamp);
|
||||
expect(resetTimestamp).toBeLessThanOrEqual(nowTimestamp + 30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBucketLimit', () => {
|
||||
it('should track by bucket name', async () => {
|
||||
const config = {limit: 10, windowMs: 60000};
|
||||
|
||||
const result = await service.checkBucketLimit('api:messages', config);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should isolate different buckets', async () => {
|
||||
const config = {limit: 2, windowMs: 5000};
|
||||
|
||||
await service.checkBucketLimit('bucket:a', config);
|
||||
await service.checkBucketLimit('bucket:a', config);
|
||||
const aBlocked = await service.checkBucketLimit('bucket:a', config);
|
||||
|
||||
const bAllowed = await service.checkBucketLimit('bucket:b', config);
|
||||
|
||||
expect(aBlocked.allowed).toBe(false);
|
||||
expect(bAllowed.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkGlobalLimit', () => {
|
||||
it('should use 1 second window', async () => {
|
||||
const limit = 5;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkGlobalLimit('ip:test', limit);
|
||||
}
|
||||
|
||||
const blocked = await service.checkGlobalLimit('ip:test', limit);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.global).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
const allowed = await service.checkGlobalLimit('ip:test', limit);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should include global flag', async () => {
|
||||
const result = await service.checkGlobalLimit('ip:flag', 10);
|
||||
expect(result.global).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetLimit', () => {
|
||||
it('should clear rate limit state', async () => {
|
||||
const config = {
|
||||
identifier: 'user:reset',
|
||||
maxAttempts: 2,
|
||||
windowMs: 60000,
|
||||
};
|
||||
|
||||
await service.checkLimit(config);
|
||||
await service.checkLimit(config);
|
||||
const blocked = await service.checkLimit(config);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
await service.resetLimit('user:reset');
|
||||
|
||||
const allowed = await service.checkLimit(config);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
expect(allowed.remaining).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle limit of 1', async () => {
|
||||
const config = {
|
||||
identifier: 'edge:one',
|
||||
maxAttempts: 1,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
const first = await service.checkLimit(config);
|
||||
expect(first.allowed).toBe(true);
|
||||
expect(first.remaining).toBe(0);
|
||||
|
||||
const second = await service.checkLimit(config);
|
||||
expect(second.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle very large limits', async () => {
|
||||
const config = {
|
||||
identifier: 'edge:large',
|
||||
maxAttempts: 1000000,
|
||||
windowMs: 60000,
|
||||
};
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBeGreaterThan(999990);
|
||||
});
|
||||
|
||||
it('should handle very short windows', async () => {
|
||||
const config = {
|
||||
identifier: 'edge:short',
|
||||
maxAttempts: 5,
|
||||
windowMs: 100,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(25);
|
||||
|
||||
const allowed = await service.checkLimit(config);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle concurrent requests', async () => {
|
||||
const config = {
|
||||
identifier: 'edge:concurrent',
|
||||
maxAttempts: 10,
|
||||
windowMs: 60000,
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
service.checkLimit(config),
|
||||
service.checkLimit(config),
|
||||
service.checkLimit(config),
|
||||
service.checkLimit(config),
|
||||
service.checkLimit(config),
|
||||
]);
|
||||
|
||||
const allowedCount = results.filter((r) => r.allowed).length;
|
||||
expect(allowedCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle special characters in identifier', async () => {
|
||||
const config = {
|
||||
identifier: 'user:test@example.com:action:post:/api/v1/messages',
|
||||
maxAttempts: 5,
|
||||
windowMs: 60000,
|
||||
};
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle corrupted cache data', async () => {
|
||||
await cache.set('ratelimit:corrupted', {invalid: 'data'}, 60);
|
||||
|
||||
const config = {
|
||||
identifier: 'corrupted',
|
||||
maxAttempts: 5,
|
||||
windowMs: 60000,
|
||||
};
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle NaN in cache', async () => {
|
||||
await cache.set('ratelimit:nan', {tat: NaN}, 60);
|
||||
|
||||
const config = {
|
||||
identifier: 'nan',
|
||||
maxAttempts: 5,
|
||||
windowMs: 60000,
|
||||
};
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle Infinity in cache', async () => {
|
||||
await cache.set('ratelimit:inf', {tat: Infinity}, 60);
|
||||
|
||||
const config = {
|
||||
identifier: 'inf',
|
||||
maxAttempts: 5,
|
||||
windowMs: 60000,
|
||||
};
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GCRA algorithm verification', () => {
|
||||
it('should enforce emission interval after burst', async () => {
|
||||
const config = {
|
||||
identifier: 'gcra:emission',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
const blocked = await service.checkLimit(config);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
const stillBlocked = await service.checkLimit(config);
|
||||
expect(stillBlocked.allowed).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(2);
|
||||
const allowed = await service.checkLimit(config);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should track theoretical arrival time correctly', async () => {
|
||||
const config = {
|
||||
identifier: 'gcra:tat',
|
||||
maxAttempts: 10,
|
||||
windowMs: 10000,
|
||||
};
|
||||
|
||||
const r1 = await service.checkLimit(config);
|
||||
expect(r1.resetTime.getTime()).toBe(Date.now() + 1000);
|
||||
|
||||
await service.checkLimit(config);
|
||||
const r2 = await service.checkLimit(config);
|
||||
expect(r2.resetTime.getTime()).toBe(Date.now() + 3000);
|
||||
});
|
||||
|
||||
it('should handle time advancing past TAT', async () => {
|
||||
const config = {
|
||||
identifier: 'gcra:past-tat',
|
||||
maxAttempts: 5,
|
||||
windowMs: 5000,
|
||||
};
|
||||
|
||||
await service.checkLimit(config);
|
||||
await service.checkLimit(config);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remaining count accuracy', () => {
|
||||
it('should show correct remaining after partial refill', async () => {
|
||||
const config = {
|
||||
identifier: 'remaining:partial',
|
||||
maxAttempts: 10,
|
||||
windowMs: 10000,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await service.checkLimit(config);
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
const result = await service.checkLimit(config);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInMemoryRateLimitService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return null when disabled', () => {
|
||||
const service = createInMemoryRateLimitService(false);
|
||||
expect(service).toBeNull();
|
||||
});
|
||||
|
||||
it('should return RateLimitService when enabled', () => {
|
||||
const service = createInMemoryRateLimitService(true);
|
||||
expect(service).toBeInstanceOf(RateLimitService);
|
||||
});
|
||||
|
||||
it('should be functional', async () => {
|
||||
const service = createInMemoryRateLimitService(true)!;
|
||||
|
||||
const result = await service.checkLimit({
|
||||
identifier: 'test',
|
||||
maxAttempts: 5,
|
||||
windowMs: 60000,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
163
packages/rate_limit/src/internal/GcraRateLimiter.tsx
Normal file
163
packages/rate_limit/src/internal/GcraRateLimiter.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ICacheService} from '@fluxer/rate_limit/src/ICacheService';
|
||||
import type {RateLimitResult} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import {
|
||||
parseRateLimitCacheState,
|
||||
type RateLimitCacheState,
|
||||
serializeRateLimitCacheState,
|
||||
} from '@fluxer/rate_limit/src/internal/RateLimitCacheState';
|
||||
import {assertPositiveFiniteNumber} from '@fluxer/rate_limit/src/internal/RateLimitValidation';
|
||||
|
||||
export interface GcraRule {
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
interface GcraCheckOptions {
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
export class GcraRateLimiter {
|
||||
private static readonly MIN_RETRY_AFTER_SECONDS = 1;
|
||||
private static readonly MIN_RETRY_AFTER_DECIMAL_SECONDS = 0.001;
|
||||
|
||||
constructor(
|
||||
private readonly cacheService: ICacheService,
|
||||
private readonly getCurrentTimeMs: () => number = () => Date.now(),
|
||||
) {}
|
||||
|
||||
private async getCacheState(key: string): Promise<RateLimitCacheState | null> {
|
||||
const rawState = await this.cacheService.get<unknown>(key);
|
||||
return parseRateLimitCacheState(rawState);
|
||||
}
|
||||
|
||||
private static calculateEmissionIntervalMs(rule: GcraRule): number {
|
||||
return rule.windowMs / rule.limit;
|
||||
}
|
||||
|
||||
private static calculateRemaining(
|
||||
limit: number,
|
||||
emissionIntervalMs: number,
|
||||
nextTatMs: number,
|
||||
nowMs: number,
|
||||
): number {
|
||||
const debtMs = Math.max(0, nextTatMs - nowMs);
|
||||
const usedCapacity = debtMs / emissionIntervalMs;
|
||||
return Math.max(0, Math.floor(limit - usedCapacity));
|
||||
}
|
||||
|
||||
private static createAllowedResult(
|
||||
limit: number,
|
||||
remaining: number,
|
||||
resetTimeMs: number,
|
||||
global: boolean | undefined,
|
||||
): RateLimitResult {
|
||||
return {
|
||||
allowed: true,
|
||||
limit,
|
||||
remaining,
|
||||
resetTime: new Date(resetTimeMs),
|
||||
...(global !== undefined && {global}),
|
||||
};
|
||||
}
|
||||
|
||||
private static createBlockedResult(
|
||||
limit: number,
|
||||
resetTimeMs: number,
|
||||
retryAfterMs: number,
|
||||
global: boolean | undefined,
|
||||
): RateLimitResult {
|
||||
const retryAfter = Math.max(GcraRateLimiter.MIN_RETRY_AFTER_SECONDS, Math.ceil(retryAfterMs / 1000));
|
||||
const retryAfterDecimal = Math.max(GcraRateLimiter.MIN_RETRY_AFTER_DECIMAL_SECONDS, retryAfterMs / 1000);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
limit,
|
||||
remaining: 0,
|
||||
resetTime: new Date(resetTimeMs),
|
||||
retryAfter,
|
||||
retryAfterDecimal,
|
||||
...(global !== undefined && {global}),
|
||||
};
|
||||
}
|
||||
|
||||
async checkLimit(key: string, rule: GcraRule, options: GcraCheckOptions = {}): Promise<RateLimitResult> {
|
||||
assertPositiveFiniteNumber(rule.limit, 'rule.limit');
|
||||
assertPositiveFiniteNumber(rule.windowMs, 'rule.windowMs');
|
||||
|
||||
const nowMs = this.getCurrentTimeMs();
|
||||
const emissionIntervalMs = GcraRateLimiter.calculateEmissionIntervalMs(rule);
|
||||
const burstCapacityMs = rule.windowMs;
|
||||
|
||||
const state = await this.getCacheState(key);
|
||||
const currentTatMs = state?.tatMs ?? nowMs;
|
||||
const nextTatMs = Math.max(currentTatMs, nowMs) + emissionIntervalMs;
|
||||
const allowAtMs = nextTatMs - burstCapacityMs;
|
||||
|
||||
if (nowMs >= allowAtMs) {
|
||||
const ttlMs = nextTatMs - nowMs;
|
||||
const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
|
||||
await this.cacheService.set(
|
||||
key,
|
||||
serializeRateLimitCacheState({
|
||||
tatMs: nextTatMs,
|
||||
limit: rule.limit,
|
||||
windowMs: rule.windowMs,
|
||||
}),
|
||||
ttlSeconds,
|
||||
);
|
||||
|
||||
const remaining = GcraRateLimiter.calculateRemaining(rule.limit, emissionIntervalMs, nextTatMs, nowMs);
|
||||
return GcraRateLimiter.createAllowedResult(rule.limit, remaining, nextTatMs, options.global);
|
||||
}
|
||||
|
||||
const retryAfterMs = allowAtMs - nowMs;
|
||||
return GcraRateLimiter.createBlockedResult(rule.limit, currentTatMs, retryAfterMs, options.global);
|
||||
}
|
||||
|
||||
async resetLimit(key: string): Promise<void> {
|
||||
await this.cacheService.delete(key);
|
||||
}
|
||||
|
||||
async getRemainingAttempts(key: string): Promise<number> {
|
||||
const state = await this.getCacheState(key);
|
||||
if (!state?.limit || !state.windowMs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nowMs = this.getCurrentTimeMs();
|
||||
const emissionIntervalMs = GcraRateLimiter.calculateEmissionIntervalMs({
|
||||
limit: state.limit,
|
||||
windowMs: state.windowMs,
|
||||
});
|
||||
|
||||
return GcraRateLimiter.calculateRemaining(state.limit, emissionIntervalMs, state.tatMs, nowMs);
|
||||
}
|
||||
|
||||
async getResetTime(key: string): Promise<Date> {
|
||||
const state = await this.getCacheState(key);
|
||||
if (!state) {
|
||||
return new Date(this.getCurrentTimeMs());
|
||||
}
|
||||
|
||||
return new Date(state.tatMs);
|
||||
}
|
||||
}
|
||||
96
packages/rate_limit/src/internal/RateLimitCacheState.tsx
Normal file
96
packages/rate_limit/src/internal/RateLimitCacheState.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {assertPositiveFiniteNumber} from '@fluxer/rate_limit/src/internal/RateLimitValidation';
|
||||
|
||||
interface LegacyRateLimitCacheState {
|
||||
tat?: unknown;
|
||||
tat_ms?: unknown;
|
||||
limit?: unknown;
|
||||
window_ms?: unknown;
|
||||
}
|
||||
|
||||
export interface RateLimitCacheState {
|
||||
tatMs: number;
|
||||
limit?: number;
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
interface SerializedRateLimitCacheState {
|
||||
tat: number;
|
||||
tat_ms: number;
|
||||
limit?: number;
|
||||
window_ms?: number;
|
||||
}
|
||||
|
||||
function getOptionalPositiveFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getTatMs(value: LegacyRateLimitCacheState): number | null {
|
||||
const tatMsCandidate = value.tat_ms ?? value.tat;
|
||||
if (typeof tatMsCandidate !== 'number' || !Number.isFinite(tatMsCandidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tatMsCandidate;
|
||||
}
|
||||
|
||||
export function parseRateLimitCacheState(rawValue: unknown): RateLimitCacheState | null {
|
||||
if (!rawValue || typeof rawValue !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawState = rawValue as LegacyRateLimitCacheState;
|
||||
const tatMs = getTatMs(rawState);
|
||||
if (tatMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tatMs,
|
||||
limit: getOptionalPositiveFiniteNumber(rawState.limit),
|
||||
windowMs: getOptionalPositiveFiniteNumber(rawState.window_ms),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeRateLimitCacheState(state: RateLimitCacheState): SerializedRateLimitCacheState {
|
||||
assertPositiveFiniteNumber(state.tatMs, 'state.tatMs');
|
||||
|
||||
const serializedState: SerializedRateLimitCacheState = {
|
||||
tat: state.tatMs,
|
||||
tat_ms: state.tatMs,
|
||||
};
|
||||
|
||||
if (state.limit !== undefined) {
|
||||
assertPositiveFiniteNumber(state.limit, 'state.limit');
|
||||
serializedState.limit = state.limit;
|
||||
}
|
||||
|
||||
if (state.windowMs !== undefined) {
|
||||
assertPositiveFiniteNumber(state.windowMs, 'state.windowMs');
|
||||
serializedState.window_ms = state.windowMs;
|
||||
}
|
||||
|
||||
return serializedState;
|
||||
}
|
||||
47
packages/rate_limit/src/internal/RateLimitKeyFactory.tsx
Normal file
47
packages/rate_limit/src/internal/RateLimitKeyFactory.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {assertNonEmptyString} from '@fluxer/rate_limit/src/internal/RateLimitValidation';
|
||||
|
||||
const RATE_LIMIT_PREFIX = 'ratelimit';
|
||||
const BUCKET_NAMESPACE = 'bucket';
|
||||
const GLOBAL_NAMESPACE = 'global';
|
||||
|
||||
export interface IRateLimitKeyFactory {
|
||||
getIdentifierKey(identifier: string): string;
|
||||
getBucketKey(bucket: string): string;
|
||||
getGlobalKey(identifier: string): string;
|
||||
}
|
||||
|
||||
export class RateLimitKeyFactory implements IRateLimitKeyFactory {
|
||||
getIdentifierKey(identifier: string): string {
|
||||
assertNonEmptyString(identifier, 'identifier');
|
||||
return `${RATE_LIMIT_PREFIX}:${identifier}`;
|
||||
}
|
||||
|
||||
getBucketKey(bucket: string): string {
|
||||
assertNonEmptyString(bucket, 'bucket');
|
||||
return `${RATE_LIMIT_PREFIX}:${BUCKET_NAMESPACE}:${bucket}`;
|
||||
}
|
||||
|
||||
getGlobalKey(identifier: string): string {
|
||||
assertNonEmptyString(identifier, 'identifier');
|
||||
return `${RATE_LIMIT_PREFIX}:${GLOBAL_NAMESPACE}:${identifier}`;
|
||||
}
|
||||
}
|
||||
30
packages/rate_limit/src/internal/RateLimitValidation.tsx
Normal file
30
packages/rate_limit/src/internal/RateLimitValidation.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function assertPositiveFiniteNumber(value: number, fieldName: string): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${fieldName} must be a positive finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNonEmptyString(value: string, fieldName: string): void {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`${fieldName} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
110
packages/rate_limit/src/middleware/RateLimitMiddleware.tsx
Normal file
110
packages/rate_limit/src/middleware/RateLimitMiddleware.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import type {BucketConfig, IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {Context, MiddlewareHandler} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
export interface RateLimitMiddlewareConfig {
|
||||
enabled: boolean;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
skipPaths?: Array<string>;
|
||||
}
|
||||
|
||||
export type RateLimitServiceProvider = IRateLimitService | null | (() => IRateLimitService | null);
|
||||
|
||||
export interface RateLimitMiddlewareOptions<_Variables extends Record<string, unknown> = Record<string, unknown>> {
|
||||
rateLimitService: RateLimitServiceProvider;
|
||||
config: RateLimitMiddlewareConfig;
|
||||
getClientIdentifier: (req: Request) => string;
|
||||
getBucketName?: (identifier: string, c: Context) => string;
|
||||
onRateLimitExceeded?: (c: Context, retryAfter: number) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export function setRateLimitHeaders(
|
||||
ctx: {header: (name: string, value: string) => void},
|
||||
limit: number,
|
||||
remaining: number,
|
||||
resetTime: Date,
|
||||
): void {
|
||||
ctx.header('X-RateLimit-Limit', limit.toString());
|
||||
ctx.header('X-RateLimit-Remaining', remaining.toString());
|
||||
ctx.header('X-RateLimit-Reset', Math.floor(resetTime.getTime() / 1000).toString());
|
||||
}
|
||||
|
||||
function shouldSkipPath(path: string, skipPaths: Array<string>): boolean {
|
||||
for (const skipPath of skipPaths) {
|
||||
if (path === skipPath) return true;
|
||||
if (skipPath.endsWith('/') && path.startsWith(skipPath)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createRateLimitMiddleware<Variables extends Record<string, unknown> = Record<string, unknown>>(
|
||||
options: RateLimitMiddlewareOptions<Variables>,
|
||||
): MiddlewareHandler<{Variables: Variables}> {
|
||||
const {rateLimitService, config, getClientIdentifier, getBucketName, onRateLimitExceeded} = options;
|
||||
|
||||
return createMiddleware<{Variables: Variables}>(async (c, next) => {
|
||||
const resolvedRateLimitService = typeof rateLimitService === 'function' ? rateLimitService() : rateLimitService;
|
||||
if (!config.enabled) {
|
||||
await next();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!resolvedRateLimitService) {
|
||||
await next();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = c.req.path;
|
||||
if (config.skipPaths && shouldSkipPath(path, config.skipPaths)) {
|
||||
await next();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const identifier = getClientIdentifier(c.req.raw);
|
||||
const bucket = getBucketName ? getBucketName(identifier, c) : `${identifier}:global`;
|
||||
|
||||
const bucketConfig: BucketConfig = {
|
||||
limit: config.limit,
|
||||
windowMs: config.windowMs,
|
||||
};
|
||||
|
||||
const result = await resolvedRateLimitService.checkBucketLimit(bucket, bucketConfig);
|
||||
|
||||
if (!result.allowed) {
|
||||
const retryAfter = result.retryAfter ?? 1;
|
||||
c.header('Retry-After', retryAfter.toString());
|
||||
setRateLimitHeaders(c, result.limit, result.remaining, result.resetTime);
|
||||
|
||||
if (onRateLimitExceeded) {
|
||||
return onRateLimitExceeded(c, retryAfter);
|
||||
}
|
||||
|
||||
return c.json({error: 'Rate limit exceeded', retryAfter}, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
setRateLimitHeaders(c, result.limit, result.remaining, result.resetTime);
|
||||
await next();
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
5
packages/rate_limit/tsconfig.json
Normal file
5
packages/rate_limit/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
44
packages/rate_limit/vitest.config.ts
Normal file
44
packages/rate_limit/vitest.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user