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
20 changes: 18 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { CorrelationIdInterceptor } from './common/interceptors/correlation-id.interceptor';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
import { RequestLoggingInterceptor } from './common/interceptors/request-logging.interceptor';
import { GracefulShutdownInterceptor } from './common/interceptors/graceful-shutdown.interceptor';
import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard';
import { CommonModule } from './common/common.module';
Expand Down Expand Up @@ -39,6 +40,10 @@ import { TestRbacModule } from './test-rbac/test-rbac.module';
import { TestThrottlingModule } from './test-throttling/test-throttling.module';
import { ApiVersioningModule } from './common/versioning/api-versioning.module';
import { BackupModule } from './modules/backup/backup.module';
import { ConnectionPoolModule } from './common/database/connection-pool.module';
import { CircuitBreakerModule } from './common/circuit-breaker/circuit-breaker.module';
import { PostmanModule } from './common/postman/postman.module';
import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware';
import { PerformanceModule } from './modules/performance/performance.module';
import { GracefulShutdownService } from './common/services/graceful-shutdown.service';

Expand Down Expand Up @@ -195,6 +200,9 @@ const envValidationSchema = Joi.object({
TestThrottlingModule,
ApiVersioningModule,
BackupModule,
ConnectionPoolModule,
CircuitBreakerModule,
PostmanModule,
PerformanceModule,
CommonModule,
ThrottlerModule.forRoot([
Expand Down Expand Up @@ -223,6 +231,10 @@ const envValidationSchema = Joi.object({
provide: APP_GUARD,
useClass: TieredThrottlerGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: RequestLoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: CorrelationIdInterceptor,
Expand All @@ -237,4 +249,8 @@ const envValidationSchema = Joi.object({
},
],
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
}
}
171 changes: 171 additions & 0 deletions backend/src/common/circuit-breaker/circuit-breaker.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

export enum CircuitBreakerState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN',
}

export interface CircuitBreakerMetrics {
state: CircuitBreakerState;
failureCount: number;
successCount: number;
lastFailureTime?: Date;
lastSuccessTime?: Date;
totalRequests: number;
failureRate: number;
}

@Injectable()
export class CircuitBreaker {
private readonly logger = new Logger(CircuitBreaker.name);
private state: CircuitBreakerState = CircuitBreakerState.CLOSED;
private failureCount = 0;
private successCount = 0;
private totalRequests = 0;
private lastFailureTime?: Date;
private lastSuccessTime?: Date;
private openedAt?: Date;

private readonly failureThreshold: number;
private readonly successThreshold: number;
private readonly timeout: number;
private readonly halfOpenRequests: number;
private halfOpenAttempts = 0;

constructor(
private configService: ConfigService,
private name: string,
) {
this.failureThreshold = configService.get<number>(
'CIRCUIT_BREAKER_FAILURE_THRESHOLD',
5,
);
this.successThreshold = configService.get<number>(
'CIRCUIT_BREAKER_SUCCESS_THRESHOLD',
2,
);
this.timeout = configService.get<number>(
'CIRCUIT_BREAKER_TIMEOUT',
60000,
);
this.halfOpenRequests = configService.get<number>(
'CIRCUIT_BREAKER_HALF_OPEN_REQUESTS',
3,
);
}

async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === CircuitBreakerState.OPEN) {
if (this.shouldAttemptReset()) {
this.state = CircuitBreakerState.HALF_OPEN;
this.halfOpenAttempts = 0;
this.logger.warn(
`[${this.name}] Circuit breaker transitioning to HALF_OPEN`,
);
} else {
throw new Error(
`[${this.name}] Circuit breaker is OPEN. Service unavailable.`,
);
}
}

try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}

private onSuccess() {
this.successCount++;
this.totalRequests++;
this.lastSuccessTime = new Date();

if (this.state === CircuitBreakerState.HALF_OPEN) {
this.halfOpenAttempts++;
if (this.halfOpenAttempts >= this.successThreshold) {
this.reset();
this.logger.log(
`[${this.name}] Circuit breaker CLOSED after successful recovery`,
);
}
} else if (this.state === CircuitBreakerState.CLOSED) {
this.failureCount = 0;
}
}

private onFailure() {
this.failureCount++;
this.totalRequests++;
this.lastFailureTime = new Date();

if (this.state === CircuitBreakerState.HALF_OPEN) {
this.trip();
this.logger.error(
`[${this.name}] Circuit breaker OPEN after failure in HALF_OPEN state`,
);
} else if (
this.state === CircuitBreakerState.CLOSED &&
this.failureCount >= this.failureThreshold
) {
this.trip();
this.logger.error(
`[${this.name}] Circuit breaker OPEN after ${this.failureCount} failures`,
);
}
}

private trip() {
this.state = CircuitBreakerState.OPEN;
this.openedAt = new Date();
}

private reset() {
this.state = CircuitBreakerState.CLOSED;
this.failureCount = 0;
this.successCount = 0;
this.openedAt = undefined;
}

private shouldAttemptReset(): boolean {
if (!this.openedAt) return false;
const elapsed = Date.now() - this.openedAt.getTime();
return elapsed >= this.timeout;
}

getMetrics(): CircuitBreakerMetrics {
const failureRate =
this.totalRequests > 0
? (this.failureCount / this.totalRequests) * 100
: 0;

return {
state: this.state,
failureCount: this.failureCount,
successCount: this.successCount,
lastFailureTime: this.lastFailureTime,
lastSuccessTime: this.lastSuccessTime,
totalRequests: this.totalRequests,
failureRate,
};
}

getState(): CircuitBreakerState {
return this.state;
}

manualOpen() {
this.trip();
this.logger.warn(`[${this.name}] Circuit breaker manually opened`);
}

manualClose() {
this.reset();
this.logger.log(`[${this.name}] Circuit breaker manually closed`);
}
}
8 changes: 8 additions & 0 deletions backend/src/common/circuit-breaker/circuit-breaker.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CircuitBreakerService } from './circuit-breaker.service';

@Module({
providers: [CircuitBreakerService],
exports: [CircuitBreakerService],
})
export class CircuitBreakerModule {}
91 changes: 91 additions & 0 deletions backend/src/common/circuit-breaker/circuit-breaker.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CircuitBreaker, CircuitBreakerMetrics } from './circuit-breaker.config';

@Injectable()
export class CircuitBreakerService {
private readonly logger = new Logger(CircuitBreakerService.name);
private breakers: Map<string, CircuitBreaker> = new Map();
private readonly rpcEndpoints: string[];

constructor(private configService: ConfigService) {
this.rpcEndpoints = [
this.configService.get<string>(
'SOROBAN_RPC_URL',
'https://soroban-testnet.stellar.org',
),
this.configService.get<string>(
'SOROBAN_RPC_FALLBACK_URL',
'https://soroban-testnet.stellar.org',
),
].filter((url) => url);

this.initializeBreakers();
}

private initializeBreakers() {
this.rpcEndpoints.forEach((endpoint) => {
const name = `RPC-${new URL(endpoint).hostname}`;
this.breakers.set(name, new CircuitBreaker(this.configService, name));
});
}

async executeWithFallback<T>(
fn: (endpoint: string) => Promise<T>,
): Promise<T> {
const errors: Error[] = [];

for (const endpoint of this.rpcEndpoints) {
const breakerName = `RPC-${new URL(endpoint).hostname}`;
const breaker = this.breakers.get(breakerName);

if (!breaker) continue;

try {
return await breaker.execute(() => fn(endpoint));
} catch (error) {
errors.push(error as Error);
this.logger.warn(
`Endpoint ${endpoint} failed, trying next endpoint`,
error,
);
}
}

this.logger.error('All RPC endpoints exhausted', errors);
throw new Error(
'All RPC endpoints are unavailable. Graceful degradation: returning cached data or default values.',
);
}

getMetrics(breakerName?: string): CircuitBreakerMetrics | Map<string, CircuitBreakerMetrics> {
if (breakerName) {
const breaker = this.breakers.get(breakerName);
return breaker ? breaker.getMetrics() : null;
}

const metrics = new Map<string, CircuitBreakerMetrics>();
this.breakers.forEach((breaker, name) => {
metrics.set(name, breaker.getMetrics());
});
return metrics;
}

manualOpen(breakerName: string) {
const breaker = this.breakers.get(breakerName);
if (breaker) {
breaker.manualOpen();
}
}

manualClose(breakerName: string) {
const breaker = this.breakers.get(breakerName);
if (breaker) {
breaker.manualClose();
}
}

getAllBreakers(): string[] {
return Array.from(this.breakers.keys());
}
}
Loading