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
9 changes: 9 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import { CustomThrottleGuard } from './common/guards/throttle.guard';
import { loadFeatureFlags } from './config/feature-flags.config';
import { StartupLogger } from './common/lazy-loading/startup-logger.service';
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
import { ApiVersioningModule } from './common/modules/api-versioning.module';

// Feature modules - conditionally loaded based on feature flags
Expand Down Expand Up @@ -52,7 +53,9 @@
import { PaymentsModule } from './payments/payments.module';
import { LocalizationModule } from './localization/localization.module';
import { CsrfModule } from './common/csrf/csrf.module';
import { TimeoutModule } from './common/timeout/timeout.module';


Check failure on line 58 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / ESLint

Delete `⏎`
@Module({})
export class AppModule {
static async forRoot(): Promise<DynamicModule> {
Expand Down Expand Up @@ -130,6 +133,8 @@
HealthModule,
DatabaseModule,
CsrfModule,
TimeoutModule,

Check failure on line 136 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / ESLint

Delete `⏎`

];

// Feature modules - conditionally loaded based on feature flags
Expand Down Expand Up @@ -383,6 +388,10 @@
provide: APP_INTERCEPTOR,
useClass: MonitoringInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TimeoutInterceptor,
},
{
provide: APP_GUARD,
useClass: CustomThrottleGuard,
Expand Down
1 change: 1 addition & 0 deletions src/assessment/entities/assessment.entity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Index,
Expand Down
43 changes: 43 additions & 0 deletions src/common/examples/timeout-example.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Controller, Get, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Timeout } from '../interceptors/timeout.interceptor';

@ApiTags('Timeout Examples')
@Controller('examples')
export class TimeoutExampleController {
@Get('quick')
@ApiOperation({ summary: 'Quick endpoint with custom timeout' })
@Timeout(5000) // 5 seconds timeout
getQuickResponse(): { message: string } {
// This endpoint will timeout after 5 seconds
return { message: 'Quick response' };
}

@Get('slow')
@ApiOperation({ summary: 'Slow endpoint with longer timeout' })
@Timeout(120000) // 2 minutes timeout
async getSlowResponse(): Promise<{ message: string }> {
// Simulate a slow operation
await new Promise((resolve) => setTimeout(resolve, 10000)); // 10 second delay
return { message: 'Slow response completed' };
}

@Post('process')
@ApiOperation({ summary: 'Processing endpoint with custom timeout' })
@Timeout(60000) // 1 minute timeout
async processData(@Body() data: any): Promise<{ result: string }> {

Check warning on line 28 in src/common/examples/timeout-example.controller.ts

View workflow job for this annotation

GitHub Actions / ESLint

'data' is defined but never used. Allowed unused args must match /^_/u
// Simulate data processing
await new Promise((resolve) => setTimeout(resolve, 30000)); // 30 second processing
return { result: 'Data processed successfully' };
}

@Get('default')
@ApiOperation({ summary: 'Endpoint using default timeout' })
getDefaultTimeout(): { message: string; timeout: string } {
// This endpoint will use the default timeout from configuration
return {
message: 'Using default timeout',
timeout: 'Configured by REQUEST_TIMEOUT env var or default 30s',
};
}
}
31 changes: 28 additions & 3 deletions src/common/interceptors/timeout.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
ExecutionContext,
CallHandler,
BadGatewayException,
Logger,
Inject,
} from '@nestjs/common';
import { Observable, TimeoutError } from 'rxjs';
import { timeout, catchError } from 'rxjs/operators';
import { TimeoutConfigService } from '../timeout/timeout-config.service';

export const DEFAULT_TIMEOUT = parseInt(process.env.REQUEST_TIMEOUT || '10000', 10); // ms
export const DEFAULT_TIMEOUT = parseInt(process.env.REQUEST_TIMEOUT || '30000', 10); // 30 seconds default

export function Timeout(ms?: number): MethodDecorator {
return (target, propertyKey, descriptor) => {
Expand All @@ -18,19 +21,41 @@

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
private readonly logger = new Logger(TimeoutInterceptor.name);

constructor(@Inject(TimeoutConfigService) private timeoutConfig: TimeoutConfigService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const handler = context.getHandler();
const controller = context.getClass();

Check warning on line 30 in src/common/interceptors/timeout.interceptor.ts

View workflow job for this annotation

GitHub Actions / ESLint

'controller' is assigned a value but never used. Allowed unused vars must match /^_/u
const customTimeout = Reflect.getMetadata('timeout', handler);
const timeoutValue = customTimeout || DEFAULT_TIMEOUT;

const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;

// Determine timeout value with priority: decorator > config service > default
let timeoutValue: number;
if (customTimeout) {
timeoutValue = customTimeout;
this.logger.debug(`Using decorator timeout of ${timeoutValue}ms for ${method} ${url}`);
} else {
timeoutValue = this.timeoutConfig.getTimeoutForRequest(method, url);
this.logger.debug(`Using config timeout of ${timeoutValue}ms for ${method} ${url}`);
}

return next.handle().pipe(
timeout(timeoutValue),
catchError((err) => {
if (err instanceof TimeoutError) {
this.logger.warn(`Request timeout: ${method} ${url} after ${timeoutValue}ms`);
throw new BadGatewayException({
statusCode: 504,
message: 'Request timed out',
message: `Request timed out after ${timeoutValue}ms`,
error: 'Timeout',
timestamp: new Date().toISOString(),
path: url,
method,
});
}
throw err;
Expand Down
105 changes: 105 additions & 0 deletions src/common/timeout/timeout-config.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

export interface TimeoutConfig {
default: number;
endpoints: Record<string, number>;
methods: Record<string, number>;
}

@Injectable()
export class TimeoutConfigService {
private readonly config: TimeoutConfig;

constructor(private configService: ConfigService) {
this.config = this.loadConfig();
}

private loadConfig(): TimeoutConfig {
return {
default: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
endpoints: {
// API endpoints with custom timeouts
'/auth/login': 10000, // 10 seconds for login
'/auth/register': 15000, // 15 seconds for registration
'/payments/create-payment-intent': 20000, // 20 seconds for payment processing
'/media/upload': 120000, // 2 minutes for file upload
'/backup/create': 300000, // 5 minutes for backup creation
'/search': 15000, // 15 seconds for search
'/email-marketing/campaigns/send': 60000, // 1 minute for campaign sending
},
methods: {
// HTTP method-specific timeouts
'GET': 30000, // 30 seconds for GET requests

Check failure on line 33 in src/common/timeout/timeout-config.service.ts

View workflow job for this annotation

GitHub Actions / ESLint

Replace `'GET'` with `GET`
'POST': 60000, // 1 minute for POST requests

Check failure on line 34 in src/common/timeout/timeout-config.service.ts

View workflow job for this annotation

GitHub Actions / ESLint

Replace `'POST'` with `POST`
'PUT': 45000, // 45 seconds for PUT requests

Check failure on line 35 in src/common/timeout/timeout-config.service.ts

View workflow job for this annotation

GitHub Actions / ESLint

Replace `'PUT'` with `PUT`
'DELETE': 30000, // 30 seconds for DELETE requests

Check failure on line 36 in src/common/timeout/timeout-config.service.ts

View workflow job for this annotation

GitHub Actions / ESLint

Replace `'DELETE'` with `DELETE`
'PATCH': 45000, // 45 seconds for PATCH requests

Check failure on line 37 in src/common/timeout/timeout-config.service.ts

View workflow job for this annotation

GitHub Actions / ESLint

Replace `'PATCH'` with `PATCH`
},
};
}

getDefaultTimeout(): number {
return this.config.default;
}

getEndpointTimeout(path: string): number | null {
// Check for exact path match
if (this.config.endpoints[path]) {
return this.config.endpoints[path];
}

// Check for pattern matches
for (const [pattern, timeout] of Object.entries(this.config.endpoints)) {
if (this.matchesPattern(path, pattern)) {
return timeout;
}
}

return null;
}

getMethodTimeout(method: string): number | null {
return this.config.methods[method.toUpperCase()] || null;
}

getTimeoutForRequest(method: string, path: string): number {
// Priority: endpoint > method > default
const endpointTimeout = this.getEndpointTimeout(path);
if (endpointTimeout) {
return endpointTimeout;
}

const methodTimeout = this.getMethodTimeout(method);
if (methodTimeout) {
return methodTimeout;
}

return this.getDefaultTimeout();
}

private matchesPattern(path: string, pattern: string): boolean {
// Simple pattern matching - can be enhanced with regex
if (pattern.includes('*')) {
const regexPattern = pattern.replace(/\*/g, '.*');
return new RegExp(`^${regexPattern}$`).test(path);
}
return false;
}

updateEndpointTimeout(path: string, timeout: number): void {
this.config.endpoints[path] = timeout;
}

updateMethodTimeout(method: string, timeout: number): void {
this.config.methods[method.toUpperCase()] = timeout;
}

updateDefaultTimeout(timeout: number): void {
this.config.default = timeout;
}

getConfig(): TimeoutConfig {
return { ...this.config };
}
}
74 changes: 74 additions & 0 deletions src/common/timeout/timeout.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Controller, Get, Put, UseGuards, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { TimeoutConfigService, TimeoutConfig } from './timeout-config.service';

@ApiTags('Timeout Configuration')
@ApiBearerAuth()
@Controller('timeout')
@UseGuards(JwtAuthGuard)
export class TimeoutController {
constructor(private readonly timeoutConfig: TimeoutConfigService) {}

@Get('config')
@ApiOperation({ summary: 'Get current timeout configuration' })
@ApiResponse({ status: 200, description: 'Timeout configuration retrieved successfully' })
getConfig(): TimeoutConfig {
return this.timeoutConfig.getConfig();
}

@Put('default')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update default timeout' })
@ApiResponse({ status: 200, description: 'Default timeout updated successfully' })
updateDefaultTimeout(@Body() body: { timeout: number }): { message: string; timeout: number } {
this.timeoutConfig.updateDefaultTimeout(body.timeout);
return {
message: 'Default timeout updated successfully',
timeout: body.timeout,
};
}

@Put('endpoint')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update endpoint timeout' })
@ApiResponse({ status: 200, description: 'Endpoint timeout updated successfully' })
updateEndpointTimeout(@Body() body: { path: string; timeout: number }): { message: string } {
this.timeoutConfig.updateEndpointTimeout(body.path, body.timeout);
return {
message: `Endpoint timeout updated successfully for ${body.path}`,
};
}

@Put('method')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update HTTP method timeout' })
@ApiResponse({ status: 200, description: 'Method timeout updated successfully' })
updateMethodTimeout(@Body() body: { method: string; timeout: number }): { message: string } {
this.timeoutConfig.updateMethodTimeout(body.method, body.timeout);
return {
message: `Method timeout updated successfully for ${body.method}`,
};
}

@Get('check')
@ApiOperation({ summary: 'Get timeout for a specific request' })
@ApiResponse({ status: 200, description: 'Timeout calculated successfully' })
checkTimeout(@Body() body: { method: string; path: string }): { timeout: number; source: string } {

Check failure on line 57 in src/common/timeout/timeout.controller.ts

View workflow job for this annotation

GitHub Actions / ESLint

Replace `·timeout:·number;·source:·string` with `⏎····timeout:·number;⏎····source:·string;⏎·`
const timeout = this.timeoutConfig.getTimeoutForRequest(body.method, body.path);
const endpointTimeout = this.timeoutConfig.getEndpointTimeout(body.path);
const methodTimeout = this.timeoutConfig.getMethodTimeout(body.method);

let source = 'default';
if (endpointTimeout) {
source = 'endpoint';
} else if (methodTimeout) {
source = 'method';
}

return {
timeout,
source,
};
}
}
11 changes: 11 additions & 0 deletions src/common/timeout/timeout.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TimeoutConfigService } from './timeout-config.service';
import { TimeoutController } from './timeout.controller';
import { TimeoutExampleController } from '../examples/timeout-example.controller';

@Module({
providers: [TimeoutConfigService],
controllers: [TimeoutController, TimeoutExampleController],
exports: [TimeoutConfigService],
})
export class TimeoutModule {}
3 changes: 1 addition & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ async function bootstrapWorker() {
app.useGlobalInterceptors(new ResponseTransformInterceptor());

// ─── Global Timeout Interceptor ─────────────────────────────────────────
const { TimeoutInterceptor } = await import('./common/interceptors/timeout.interceptor');
app.useGlobalInterceptors(new TimeoutInterceptor());
// TimeoutInterceptor is now provided globally via APP_INTERCEPTOR in AppModule

// ─── CORS ─────────────────────────────────────────────────────────────────
app.enableCors();
Expand Down
Loading