Skip to content

Commit a9e89da

Browse files
committed
feat: add rate limiting with Redis sliding window and usage metering
1 parent 1faa6aa commit a9e89da

7 files changed

Lines changed: 257 additions & 0 deletions

File tree

src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { StorageModule } from './storage/storage.module';
55
import { AuthModule } from './auth/auth.module';
66
import { TeamsModule } from './teams/teams.module';
77
import { KeysModule } from './keys/keys.module';
8+
import { RateLimitModule } from './rate-limit/rate-limit.module';
9+
import { UsageModule } from './usage/usage.module';
810
import configuration from './config/configuration';
911

1012
@Module({
@@ -17,6 +19,8 @@ import configuration from './config/configuration';
1719
AuthModule,
1820
TeamsModule,
1921
KeysModule,
22+
RateLimitModule,
23+
UsageModule,
2024
HealthModule,
2125
],
2226
})

src/rate-limit/rate-limit.guard.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
HttpException,
6+
HttpStatus,
7+
} from '@nestjs/common';
8+
import { RateLimitService } from './rate-limit.service';
9+
10+
@Injectable()
11+
export class RateLimitGuard implements CanActivate {
12+
constructor(private readonly rateLimitService: RateLimitService) {}
13+
14+
async canActivate(context: ExecutionContext): Promise<boolean> {
15+
const request = context.switchToHttp().getRequest();
16+
const response = context.switchToHttp().getResponse();
17+
18+
const apiKey = request.apiKey;
19+
const team = request.team;
20+
21+
if (!apiKey || !team) return true;
22+
23+
const result = await this.rateLimitService.check(apiKey.id, team.plan);
24+
25+
response.header('X-RateLimit-Limit', String(result.limit));
26+
response.header('X-RateLimit-Remaining', String(result.remaining));
27+
response.header('X-RateLimit-Reset', String(result.resetAt));
28+
29+
if (!result.allowed) {
30+
response.header('Retry-After', String(result.retryAfter));
31+
throw new HttpException(
32+
{ error: 'Rate limit exceeded', retryAfter: result.retryAfter },
33+
HttpStatus.TOO_MANY_REQUESTS,
34+
);
35+
}
36+
37+
return true;
38+
}
39+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module, Global } from '@nestjs/common';
2+
import { RateLimitService } from './rate-limit.service';
3+
import { RateLimitGuard } from './rate-limit.guard';
4+
5+
@Global()
6+
@Module({
7+
providers: [RateLimitService, RateLimitGuard],
8+
exports: [RateLimitService, RateLimitGuard],
9+
})
10+
export class RateLimitModule {}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import Redis from 'ioredis';
4+
5+
interface RateLimitResult {
6+
allowed: boolean;
7+
limit: number;
8+
remaining: number;
9+
resetAt: number;
10+
retryAfter?: number;
11+
}
12+
13+
const PLAN_LIMITS: Record<string, { perMinute: number; perDay: number; perMonth: number }> = {
14+
free: { perMinute: 10, perDay: 100, perMonth: 1000 },
15+
pro: { perMinute: 100, perDay: 5000, perMonth: 50000 },
16+
enterprise: { perMinute: 1000, perDay: -1, perMonth: -1 },
17+
};
18+
19+
@Injectable()
20+
export class RateLimitService {
21+
private redis: Redis;
22+
23+
constructor(private readonly config: ConfigService) {
24+
this.redis = new Redis(this.config.get<string>('REDIS_URL')!);
25+
}
26+
27+
async check(apiKeyId: string, plan: string): Promise<RateLimitResult> {
28+
const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
29+
const now = Date.now();
30+
const minuteWindow = Math.floor(now / 60000);
31+
const minuteKey = `ratelimit:${apiKeyId}:min:${minuteWindow}`;
32+
33+
const currentCount = await this.redis.incr(minuteKey);
34+
if (currentCount === 1) {
35+
await this.redis.expire(minuteKey, 120);
36+
}
37+
38+
const resetAt = (minuteWindow + 1) * 60;
39+
40+
if (limits.perMinute !== -1 && currentCount > limits.perMinute) {
41+
return {
42+
allowed: false,
43+
limit: limits.perMinute,
44+
remaining: 0,
45+
resetAt,
46+
retryAfter: Math.ceil(resetAt - now / 1000),
47+
};
48+
}
49+
50+
return {
51+
allowed: true,
52+
limit: limits.perMinute,
53+
remaining: Math.max(0, limits.perMinute - currentCount),
54+
resetAt,
55+
};
56+
}
57+
}

src/usage/usage.controller.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
2+
import { UsageService } from './usage.service';
3+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
4+
import { CurrentDeveloper } from '../auth/decorators/current-developer.decorator';
5+
6+
@Controller('usage')
7+
@UseGuards(JwtAuthGuard)
8+
export class UsageController {
9+
constructor(private readonly usageService: UsageService) {}
10+
11+
@Get('summary')
12+
summary(@CurrentDeveloper() dev: { teamId: string }) {
13+
return this.usageService.summary(dev.teamId);
14+
}
15+
16+
@Get('daily')
17+
daily(
18+
@CurrentDeveloper() dev: { teamId: string },
19+
@Query('from') from?: string,
20+
@Query('to') to?: string,
21+
) {
22+
return this.usageService.daily(dev.teamId, from, to);
23+
}
24+
25+
@Get('by-key')
26+
byKey(@CurrentDeveloper() dev: { teamId: string }) {
27+
return this.usageService.byKey(dev.teamId);
28+
}
29+
30+
@Get('by-endpoint')
31+
byEndpoint(@CurrentDeveloper() dev: { teamId: string }) {
32+
return this.usageService.byEndpoint(dev.teamId);
33+
}
34+
}

src/usage/usage.module.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Module, Global } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { UsageController } from './usage.controller';
4+
import { UsageService } from './usage.service';
5+
import { UsageLogEntity } from '../storage/entities/usage-log.entity';
6+
7+
@Global()
8+
@Module({
9+
imports: [TypeOrmModule.forFeature([UsageLogEntity])],
10+
controllers: [UsageController],
11+
providers: [UsageService],
12+
exports: [UsageService],
13+
})
14+
export class UsageModule {}

src/usage/usage.service.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository, Between } from 'typeorm';
4+
import { UsageLogEntity } from '../storage/entities/usage-log.entity';
5+
6+
interface LogEntry {
7+
teamId: string;
8+
apiKeyId: string;
9+
endpoint: string;
10+
method: string;
11+
statusCode: number;
12+
responseTimeMs: number;
13+
tokensUsed?: number;
14+
}
15+
16+
@Injectable()
17+
export class UsageService {
18+
constructor(
19+
@InjectRepository(UsageLogEntity)
20+
private readonly usageRepo: Repository<UsageLogEntity>,
21+
) {}
22+
23+
async log(entry: LogEntry) {
24+
await this.usageRepo.save(this.usageRepo.create(entry));
25+
}
26+
27+
async summary(teamId: string) {
28+
const now = new Date();
29+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
30+
31+
const result = await this.usageRepo
32+
.createQueryBuilder('log')
33+
.select('COUNT(*)', 'totalRequests')
34+
.addSelect('COALESCE(SUM(log.tokensUsed), 0)', 'totalTokens')
35+
.where('log.teamId = :teamId', { teamId })
36+
.andWhere('log.timestamp >= :start', { start: startOfMonth })
37+
.getRawOne();
38+
39+
return {
40+
period: { start: startOfMonth.toISOString(), end: now.toISOString() },
41+
totalRequests: parseInt(result.totalRequests, 10),
42+
totalTokens: parseInt(result.totalTokens, 10),
43+
};
44+
}
45+
46+
async daily(teamId: string, from?: string, to?: string) {
47+
const now = new Date();
48+
const start = from ? new Date(from) : new Date(now.getFullYear(), now.getMonth(), 1);
49+
const end = to ? new Date(to) : now;
50+
51+
const results = await this.usageRepo
52+
.createQueryBuilder('log')
53+
.select("DATE_TRUNC('day', log.timestamp)", 'date')
54+
.addSelect('COUNT(*)', 'requests')
55+
.addSelect('COALESCE(SUM(log.tokensUsed), 0)', 'tokens')
56+
.where('log.teamId = :teamId', { teamId })
57+
.andWhere('log.timestamp BETWEEN :start AND :end', { start, end })
58+
.groupBy("DATE_TRUNC('day', log.timestamp)")
59+
.orderBy('date', 'ASC')
60+
.getRawMany();
61+
62+
return results.map((r) => ({
63+
date: r.date,
64+
requests: parseInt(r.requests, 10),
65+
tokens: parseInt(r.tokens, 10),
66+
}));
67+
}
68+
69+
async byKey(teamId: string) {
70+
const now = new Date();
71+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
72+
73+
return this.usageRepo
74+
.createQueryBuilder('log')
75+
.select('log.apiKeyId', 'apiKeyId')
76+
.addSelect('COUNT(*)', 'requests')
77+
.addSelect('COALESCE(SUM(log.tokensUsed), 0)', 'tokens')
78+
.where('log.teamId = :teamId', { teamId })
79+
.andWhere('log.timestamp >= :start', { start: startOfMonth })
80+
.groupBy('log.apiKeyId')
81+
.getRawMany();
82+
}
83+
84+
async byEndpoint(teamId: string) {
85+
const now = new Date();
86+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
87+
88+
return this.usageRepo
89+
.createQueryBuilder('log')
90+
.select('log.endpoint', 'endpoint')
91+
.addSelect('COUNT(*)', 'requests')
92+
.addSelect('COALESCE(SUM(log.tokensUsed), 0)', 'tokens')
93+
.addSelect('ROUND(AVG(log.responseTimeMs))', 'avgResponseTimeMs')
94+
.where('log.teamId = :teamId', { teamId })
95+
.andWhere('log.timestamp >= :start', { start: startOfMonth })
96+
.groupBy('log.endpoint')
97+
.getRawMany();
98+
}
99+
}

0 commit comments

Comments
 (0)