diff --git a/app/backend/src/common/guards/adaptive-rate-limit.guard.ts b/app/backend/src/common/guards/adaptive-rate-limit.guard.ts index 51e62f89..fcb4ad4b 100644 --- a/app/backend/src/common/guards/adaptive-rate-limit.guard.ts +++ b/app/backend/src/common/guards/adaptive-rate-limit.guard.ts @@ -4,12 +4,14 @@ import { 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 }, @@ -20,40 +22,53 @@ export class AdaptiveRateLimitGuard implements CanActivate { constructor(private readonly redisService: RedisService) {} async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const client = this.redisService.getOrThrow(); + let client: ReturnType | null = null; + try { + client = this.redisService.getOrThrow(); + } catch { + this.logger.warn('Redis unavailable — skipping adaptive rate limiting'); + return true; + } + const request = context.switchToHttp().getRequest(); 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 ?? ''; if (path.includes('/search')) return 'search'; - const user = request.user; + const user = (request as any).user; if (user) { if (user.authType === 'apiKey' || user.authType === 'envApiKey') { return 'apiKey'; @@ -64,15 +79,14 @@ export class AdaptiveRateLimitGuard implements CanActivate { 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; + 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'; } } diff --git a/app/backend/src/health/health.controller.spec.ts b/app/backend/src/health/health.controller.spec.ts index 21bca7f3..5bb46f0c 100644 --- a/app/backend/src/health/health.controller.spec.ts +++ b/app/backend/src/health/health.controller.spec.ts @@ -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'; @@ -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 () => { @@ -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(); @@ -99,6 +109,7 @@ describe('HealthController', () => { checks: { database: expect.objectContaining({ status: 'up' }), stellarRpc: expect.objectContaining({ status: 'skipped' }), + redis: expect.objectContaining({ status: 'up' }), }, }), ); @@ -120,6 +131,7 @@ describe('HealthController', () => { checks: { database: expect.objectContaining({ status: 'down' }), stellarRpc: expect.objectContaining({ status: 'skipped' }), + redis: expect.objectContaining({ status: 'up' }), }, }), ); @@ -145,6 +157,7 @@ describe('HealthController', () => { checks: { database: expect.objectContaining({ status: 'up' }), stellarRpc: expect.objectContaining({ status: 'down' }), + redis: expect.objectContaining({ status: 'up' }), }, }), ); diff --git a/app/backend/src/health/health.module.ts b/app/backend/src/health/health.module.ts index 503f8521..0298d313 100644 --- a/app/backend/src/health/health.module.ts +++ b/app/backend/src/health/health.module.ts @@ -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], }) diff --git a/app/backend/src/health/health.service.ts b/app/backend/src/health/health.service.ts index 3c3e1962..9fe1fd5c 100644 --- a/app/backend/src/health/health.service.ts +++ b/app/backend/src/health/health.service.ts @@ -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 { @@ -33,6 +34,7 @@ export interface ReadinessResponse { checks: { database: HealthCheckResult; stellarRpc: HealthCheckResult; + redis: HealthCheckResult; }; } @@ -44,6 +46,7 @@ export class HealthService { private readonly prisma: PrismaService, @Inject(ONCHAIN_ADAPTER_TOKEN) private readonly onchainAdapter: OnchainAdapter, + private readonly redisService: RedisService, ) {} check() { @@ -84,9 +87,10 @@ export class HealthService { } async getReadiness(): Promise { - const [database, stellarRpc] = await Promise.all([ + const [database, stellarRpc, redis] = await Promise.all([ this.checkDatabase(), this.checkStellarRpc(), + this.checkRedis(), ]); const stellarRequired = this.isEnabled( @@ -105,6 +109,7 @@ export class HealthService { checks: { database, stellarRpc, + redis, }, }; } @@ -123,6 +128,21 @@ export class HealthService { }); } + private async checkRedis(): Promise { + 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 { try { await this.prisma.$queryRaw`SELECT 1`;