Skip to content
Merged
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
66 changes: 40 additions & 26 deletions app/backend/src/common/guards/adaptive-rate-limit.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
ExecutionContext,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { RedisService } from '@liaoliaots/nestjs-redis';
import { Request } from 'express';

@Injectable()
export class AdaptiveRateLimitGuard implements CanActivate {
private readonly logger = new Logger(AdaptiveRateLimitGuard.name);
private readonly limits = {
auth: { limit: 5, window: 60 },
search: { limit: 30, window: 60 },
Expand All @@ -20,42 +22,55 @@
constructor(private readonly redisService: RedisService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<any>();
const client = this.redisService.getOrThrow();
let client: ReturnType<RedisService['getOrThrow']> | null = null;
try {
client = this.redisService.getOrThrow();
} catch {
this.logger.warn('Redis unavailable — skipping adaptive rate limiting');
return true;
}

const request = context.switchToHttp().getRequest<Request>();
const strategy = this.getStrategy(request);
const { limit, window } = this.limits[strategy];
const identifier = this.getIdentifier(request);
const key = `ratelimit:${strategy}:${identifier}`;

const current = await client.incr(key);
if (current === 1) {
await client.expire(key, window);
}
try {
const current = await client.incr(key);
if (current === 1) {
await client.expire(key, window);
}

if (current > limit) {
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Too many requests, please try again later.',
strategy,
limit,
resetIn: await client.ttl(key),
},
HttpStatus.TOO_MANY_REQUESTS,
if (current > limit) {
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Too many requests, please try again later.',
strategy,
limit,
resetIn: await client.ttl(key),
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
} catch (err) {
if (err instanceof HttpException) throw err;
this.logger.warn(
`Redis operation failed — skipping rate limit check: ${err instanceof Error ? err.message : String(err)}`,
);
}

return true;
}

private getStrategy(request: any): keyof typeof this.limits {
const path = request.path ?? request.url ?? '';
private getStrategy(request: Request): keyof typeof this.limits {
const path = (request as any).path ?? (request as any).url ?? '';

Check warning on line 68 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .url on an `any` value

Check warning on line 68 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .path on an `any` value

Check warning on line 68 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an `any` value
if (path.includes('/search')) return 'search';

Check warning on line 69 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .includes on an `any` value

Check warning on line 69 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of an `any` typed value

const user = request.user;
const user = (request as any).user;

Check warning on line 71 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .user on an `any` value

Check warning on line 71 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an `any` value
if (user) {
if (user.authType === 'apiKey' || user.authType === 'envApiKey') {

Check warning on line 73 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .authType on an `any` value

Check warning on line 73 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .authType on an `any` value
return 'apiKey';
}
return 'auth';
Expand All @@ -64,15 +79,14 @@
return 'public';
}

private getIdentifier(request: any): string {
const user = request.user;
if (user?.id) return user.id;
if (user?.apiKeyId) return user.apiKeyId;
private getIdentifier(request: Request): string {
const user = (request as any).user;

Check warning on line 83 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an `any` value
if (user?.id) return user.id as string;
if (user?.apiKeyId) return user.apiKeyId as string;

const ips = (request as any).ips;
const forwardedIp =
Array.isArray(request.ips) && request.ips.length > 0
? request.ips[0]
: undefined;
Array.isArray(ips) && ips.length > 0 ? (ips[0] as string) : undefined;
return forwardedIp ?? request.ip ?? 'anonymous';
}
}
13 changes: 13 additions & 0 deletions app/backend/src/health/health.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { ConfigService } from '@nestjs/config';
import { RedisService } from '@liaoliaots/nestjs-redis';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { PrismaService } from '../prisma/prisma.service';
Expand Down Expand Up @@ -37,6 +38,14 @@ describe('HealthController', () => {
}),
};

const redisClientMock = {
ping: jest.fn().mockResolvedValue('PONG'),
};

const redisMock = {
getOrThrow: jest.fn().mockReturnValue(redisClientMock),
};

const originalFetch = global.fetch;

beforeAll(async () => {
Expand All @@ -48,6 +57,7 @@ describe('HealthController', () => {
{ provide: PrismaService, useValue: prismaMock },
{ provide: LoggerService, useValue: loggerMock },
{ provide: ONCHAIN_ADAPTER_TOKEN, useValue: onchainAdapterMock },
{ provide: RedisService, useValue: redisMock },
],
}).compile();

Expand Down Expand Up @@ -99,6 +109,7 @@ describe('HealthController', () => {
checks: {
database: expect.objectContaining({ status: 'up' }),
stellarRpc: expect.objectContaining({ status: 'skipped' }),
redis: expect.objectContaining({ status: 'up' }),
},
}),
);
Expand All @@ -120,6 +131,7 @@ describe('HealthController', () => {
checks: {
database: expect.objectContaining({ status: 'down' }),
stellarRpc: expect.objectContaining({ status: 'skipped' }),
redis: expect.objectContaining({ status: 'up' }),
},
}),
);
Expand All @@ -145,6 +157,7 @@ describe('HealthController', () => {
checks: {
database: expect.objectContaining({ status: 'up' }),
stellarRpc: expect.objectContaining({ status: 'down' }),
redis: expect.objectContaining({ status: 'up' }),
},
}),
);
Expand Down
3 changes: 2 additions & 1 deletion app/backend/src/health/health.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { LoggerModule } from '../logger/logger.module';
import { OnchainModule } from '../onchain/onchain.module';
import { RedisModule } from '@liaoliaots/nestjs-redis';

@Module({
imports: [LoggerModule, OnchainModule],
imports: [LoggerModule, OnchainModule, RedisModule],
controllers: [HealthController],
providers: [HealthService],
})
Expand Down
22 changes: 21 additions & 1 deletion app/backend/src/health/health.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisService } from '@liaoliaots/nestjs-redis';
import { PrismaService } from '../prisma/prisma.service';
import { LoggerService } from '../logger/logger.service';
import {
Expand Down Expand Up @@ -33,6 +34,7 @@ export interface ReadinessResponse {
checks: {
database: HealthCheckResult;
stellarRpc: HealthCheckResult;
redis: HealthCheckResult;
};
}

Expand All @@ -44,6 +46,7 @@ export class HealthService {
private readonly prisma: PrismaService,
@Inject(ONCHAIN_ADAPTER_TOKEN)
private readonly onchainAdapter: OnchainAdapter,
private readonly redisService: RedisService,
) {}

check() {
Expand Down Expand Up @@ -84,9 +87,10 @@ export class HealthService {
}

async getReadiness(): Promise<ReadinessResponse> {
const [database, stellarRpc] = await Promise.all([
const [database, stellarRpc, redis] = await Promise.all([
this.checkDatabase(),
this.checkStellarRpc(),
this.checkRedis(),
]);

const stellarRequired = this.isEnabled(
Expand All @@ -105,6 +109,7 @@ export class HealthService {
checks: {
database,
stellarRpc,
redis,
},
};
}
Expand All @@ -123,6 +128,21 @@ export class HealthService {
});
}

private async checkRedis(): Promise<HealthCheckResult> {
try {
const client = this.redisService.getOrThrow();
await client.ping();
return { status: 'up', details: { connected: true } };
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown Redis error';
this.logger.warn('Redis health check failed', 'HealthService', {
error: message,
});
return { status: 'down', details: { connected: false, error: message } };
}
}

private async checkDatabase(): Promise<HealthCheckResult> {
try {
await this.prisma.$queryRaw`SELECT 1`;
Expand Down
Loading