Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions spec/throttler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { assertEquals } from '../src/deps_test.ts';
import { DanetApplication } from '../src/app.ts';
import { Controller, Get } from '../src/router/controller/decorator.ts';
import { Module } from '../src/module/decorator.ts';
import { Throttle } from '../src/throttler/decorator.ts';
import { ThrottlerService, ThrottleGuard } from '../src/throttler/mod.ts';
import { GLOBAL_GUARD } from '../src/guard/constants.ts';

@Controller('throttle')
class ThrottleController {
@Throttle(2, 1)
@Get('/')
simpleGet() {
return { ok: true };
}
}

@Module({
imports: [],
controllers: [ThrottleController],
injectables: [
ThrottlerService,
{ useClass: ThrottleGuard, token: GLOBAL_GUARD },
],
})
class ThrottleModule {}

Deno.test('throttle guard limits requests', async () => {
const app = new DanetApplication();
await app.init(ThrottleModule);
const listenEvent = await app.listen(0);
const base = `http://localhost:${listenEvent.port}/throttle`;

const r1 = await fetch(base, { method: 'GET' });
assertEquals(r1.status, 200);
await r1?.body?.cancel();

const r2 = await fetch(base, { method: 'GET' });
assertEquals(r2.status, 200);
await r2?.body?.cancel();

const r3 = await fetch(base, { method: 'GET' });
assertEquals(r3.status, 429);
const json = await r3.json();
assertEquals(json, {
description: 'Too many requests',
message: '429 - Too many requests',
name: 'TooManyRequestsException',
status: 429,
});
await app.close();
});
3 changes: 2 additions & 1 deletion src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export * from './logger.ts';
export * from './events/mod.ts';
export * from './schedule/mod.ts';
export * from './kv-queue/mod.ts';
export * from './sse/mod.ts';
export * from './sse/mod.ts';
export * from './throttler/mod.ts';
20 changes: 20 additions & 0 deletions src/throttler/decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MetadataFunction, SetMetadata } from '../metadata/decorator.ts';

export const throttleMetadataKey = 'throttle';

/**
* Options for throttle metadata.
*/
export interface ThrottleOptions {
limit: number;
ttl?: number; // seconds
}

/**
* Apply throttle options to controller or handler.
* @param limit number of requests
* @param ttl seconds window
*/
export function Throttle(limit: number, ttl = 60): MetadataFunction {
return SetMetadata(throttleMetadataKey, { limit, ttl } as ThrottleOptions);
}
44 changes: 44 additions & 0 deletions src/throttler/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AuthGuard } from '../guard/interface.ts';
import { ExecutionContext } from '../router/router.ts';
import { MetadataHelper } from '../metadata/helper.ts';
import { throttleMetadataKey, ThrottleOptions } from './decorator.ts';
import { ThrottlerService } from './service.ts';
import { Injectable } from '../injector/injectable/decorator.ts';
import { TooManyRequestsException } from '../exception/http/exceptions.ts';

/**
* A guard that checks throttle metadata (method/controller) and enforces limits.
*/
@Injectable()
export class ThrottleGuard implements AuthGuard {
constructor(private throttler: ThrottlerService) {}

canActivate(context: ExecutionContext) {
const handler = context.getHandler();
const controller = context.getClass();

const methodOptions = MetadataHelper.getMetadata<ThrottleOptions>(
throttleMetadataKey,
handler,
);
const controllerOptions = MetadataHelper.getMetadata<ThrottleOptions>(
throttleMetadataKey,
controller,
);
const options = methodOptions || controllerOptions;
if (!options) {
// nothing to do
return true;
}

// default key: X-Forwarded-For header, fallbacks to remote address or global
const ip = context.req.header('x-forwarded-for') || context.req.header('x-real-ip') || 'global';
const key = `${ip}:${context.getHandler().name}`;

const count = this.throttler.consume(key, options.ttl || 60);
if (count > options.limit) {
throw new TooManyRequestsException();
}
return true;
}
}
3 changes: 3 additions & 0 deletions src/throttler/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './decorator.ts';
export * from './guard.ts';
export * from './service.ts';
31 changes: 31 additions & 0 deletions src/throttler/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Injectable } from '../injector/injectable/decorator.ts';

/**
* Very small in-memory throttler.
* Stores timestamps per key and prunes them on each hit.
*/
@Injectable()
export class ThrottlerService {
private store = new Map<string, number[]>();

/**
* Returns current count after adding this request timestamp.
* Prunes entries older than ttl seconds.
*/
consume(key: string, ttl: number): number {
const now = Date.now();
const windowStart = now - ttl * 1000;
const list = this.store.get(key) || [];
const pruned = list.filter((ts) => ts > windowStart);
pruned.push(now);
this.store.set(key, pruned);
return pruned.length;
}

/**
* For tests and diagnostics: reset store
*/
reset() {
this.store.clear();
}
}
Loading