diff --git a/middleware/src/index.ts b/middleware/src/index.ts index b4635e7..79fc8e9 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -8,4 +8,3 @@ export * from './monitoring'; export * from './validation'; export * from './common'; export * from './config'; -export * from './middleware'; diff --git a/middleware/src/middleware/advanced/circuit-breaker.middleware.ts b/middleware/src/middleware/advanced/circuit-breaker.middleware.ts deleted file mode 100644 index fcefa54..0000000 --- a/middleware/src/middleware/advanced/circuit-breaker.middleware.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { - DynamicModule, - Global, - Inject, - Injectable, - Logger, - Module, - NestMiddleware, - ServiceUnavailableException, -} from '@nestjs/common'; -import { NextFunction, Request, Response } from 'express'; - -export type CircuitBreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; - -export const CIRCUIT_BREAKER_OPTIONS = 'CIRCUIT_BREAKER_OPTIONS'; - -export interface CircuitBreakerMiddlewareOptions { - name?: string; - failureThreshold?: number; - timeoutWindowMs?: number; - halfOpenRetryIntervalMs?: number; -} - -export interface CircuitBreakerSnapshot { - name: string; - state: CircuitBreakerState; - failureCount: number; - failureThreshold: number; - timeoutWindowMs: number; - halfOpenRetryIntervalMs: number; - nextAttemptAt: number | null; -} - -@Injectable() -export class CircuitBreakerService { - private readonly logger = new Logger(CircuitBreakerService.name); - private readonly name: string; - private readonly failureThreshold: number; - private readonly timeoutWindowMs: number; - private readonly halfOpenRetryIntervalMs: number; - - private state: CircuitBreakerState = 'CLOSED'; - private failureTimestamps: number[] = []; - private nextAttemptAt: number | null = null; - private halfOpenInFlight = false; - - constructor( - @Inject(CIRCUIT_BREAKER_OPTIONS) - options: CircuitBreakerMiddlewareOptions = {}, - ) { - this.name = options.name ?? 'middleware-circuit-breaker'; - this.failureThreshold = options.failureThreshold ?? 5; - this.timeoutWindowMs = options.timeoutWindowMs ?? 10_000; - this.halfOpenRetryIntervalMs = options.halfOpenRetryIntervalMs ?? 30_000; - } - - getState(): CircuitBreakerState { - this.refreshState(); - return this.state; - } - - getSnapshot(): CircuitBreakerSnapshot { - this.refreshState(); - - return { - name: this.name, - state: this.state, - failureCount: this.failureTimestamps.length, - failureThreshold: this.failureThreshold, - timeoutWindowMs: this.timeoutWindowMs, - halfOpenRetryIntervalMs: this.halfOpenRetryIntervalMs, - nextAttemptAt: this.nextAttemptAt, - }; - } - - canRequest(): boolean { - this.refreshState(); - - if (this.state === 'OPEN') { - return false; - } - - if (this.state === 'HALF_OPEN' && this.halfOpenInFlight) { - return false; - } - - if (this.state === 'HALF_OPEN') { - this.halfOpenInFlight = true; - } - - return true; - } - - recordSuccess(): void { - const previousState = this.state; - - this.state = 'CLOSED'; - this.failureTimestamps = []; - this.nextAttemptAt = null; - this.halfOpenInFlight = false; - - if (previousState !== 'CLOSED') { - this.logger.log( - `Circuit "${this.name}" closed after a successful recovery attempt.`, - ); - } - } - - recordFailure(): void { - this.refreshState(); - this.failureTimestamps.push(Date.now()); - this.pruneFailures(); - - if ( - this.state === 'HALF_OPEN' || - this.failureTimestamps.length >= this.failureThreshold - ) { - this.openCircuit(); - return; - } - - this.logger.warn( - `Circuit "${this.name}" failure count is ${this.failureTimestamps.length}/${this.failureThreshold}.`, - ); - } - - private refreshState(): void { - this.pruneFailures(); - - if ( - this.state === 'OPEN' && - this.nextAttemptAt !== null && - Date.now() >= this.nextAttemptAt - ) { - this.state = 'HALF_OPEN'; - this.halfOpenInFlight = false; - this.logger.warn(`Circuit "${this.name}" moved to HALF_OPEN.`); - } - } - - private pruneFailures(): void { - const thresholdTime = Date.now() - this.timeoutWindowMs; - this.failureTimestamps = this.failureTimestamps.filter( - (timestamp) => timestamp >= thresholdTime, - ); - } - - private openCircuit(): void { - this.state = 'OPEN'; - this.nextAttemptAt = Date.now() + this.halfOpenRetryIntervalMs; - this.halfOpenInFlight = false; - - this.logger.error( - `Circuit "${this.name}" opened after ${this.failureTimestamps.length} failures within ${this.timeoutWindowMs}ms.`, - ); - } -} - -@Injectable() -export class CircuitBreakerMiddleware implements NestMiddleware { - private readonly logger = new Logger(CircuitBreakerMiddleware.name); - - constructor(private readonly circuitBreakerService: CircuitBreakerService) {} - - use(req: Request, res: Response, next: NextFunction): void { - if (!this.circuitBreakerService.canRequest()) { - const snapshot = this.circuitBreakerService.getSnapshot(); - const retryAt = snapshot.nextAttemptAt - ? new Date(snapshot.nextAttemptAt).toISOString() - : 'unknown'; - const message = `Circuit breaker is OPEN for ${snapshot.name}. Retry after ${retryAt}.`; - - this.logger.warn(message); - next(new ServiceUnavailableException(message)); - return; - } - - let settled = false; - - const finalizeSuccess = () => { - if (settled) { - return; - } - - settled = true; - cleanup(); - this.circuitBreakerService.recordSuccess(); - }; - - const finalizeFailure = () => { - if (settled) { - return; - } - - settled = true; - cleanup(); - this.circuitBreakerService.recordFailure(); - }; - - const onFinish = () => { - if (res.statusCode >= 500) { - finalizeFailure(); - return; - } - - finalizeSuccess(); - }; - - const onClose = () => { - if (!res.writableEnded) { - finalizeFailure(); - } - }; - - const cleanup = () => { - res.removeListener('finish', onFinish); - res.removeListener('close', onClose); - }; - - res.once('finish', onFinish); - res.once('close', onClose); - - next(); - } -} - -@Global() -@Module({}) -export class CircuitBreakerModule { - static register( - options: CircuitBreakerMiddlewareOptions = {}, - ): DynamicModule { - return { - module: CircuitBreakerModule, - providers: [ - { - provide: CIRCUIT_BREAKER_OPTIONS, - useValue: options, - }, - CircuitBreakerService, - CircuitBreakerMiddleware, - ], - exports: [CircuitBreakerService, CircuitBreakerMiddleware], - }; - } -} diff --git a/middleware/src/middleware/advanced/index.ts b/middleware/src/middleware/advanced/index.ts deleted file mode 100644 index 39b4fd9..0000000 --- a/middleware/src/middleware/advanced/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './timeout.middleware'; -export * from './circuit-breaker.middleware'; diff --git a/middleware/src/middleware/advanced/timeout.middleware.ts b/middleware/src/middleware/advanced/timeout.middleware.ts deleted file mode 100644 index 4da539c..0000000 --- a/middleware/src/middleware/advanced/timeout.middleware.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - DynamicModule, - Global, - Inject, - Injectable, - Logger, - Module, - NestMiddleware, - ServiceUnavailableException, -} from '@nestjs/common'; -import { NextFunction, Request, Response } from 'express'; - -export const TIMEOUT_MIDDLEWARE_OPTIONS = 'TIMEOUT_MIDDLEWARE_OPTIONS'; - -export interface TimeoutMiddlewareOptions { - timeoutMs?: number; - message?: string; -} - -@Injectable() -export class TimeoutMiddleware implements NestMiddleware { - private readonly logger = new Logger(TimeoutMiddleware.name); - private readonly timeoutMs: number; - private readonly message: string; - - constructor( - @Inject(TIMEOUT_MIDDLEWARE_OPTIONS) - options: TimeoutMiddlewareOptions = {}, - ) { - this.timeoutMs = options.timeoutMs ?? 5000; - this.message = - options.message ?? - `Request timed out after ${this.timeoutMs}ms while waiting for middleware execution.`; - } - - use(_req: Request, res: Response, next: NextFunction): void { - let completed = false; - - const clear = () => { - completed = true; - clearTimeout(timer); - res.removeListener('finish', onComplete); - res.removeListener('close', onComplete); - }; - - const onComplete = () => { - clear(); - }; - - const timer = setTimeout(() => { - if (completed || res.headersSent) { - return; - } - - clear(); - this.logger.warn(this.message); - next(new ServiceUnavailableException(this.message)); - }, this.timeoutMs); - - res.once('finish', onComplete); - res.once('close', onComplete); - - next(); - } -} - -@Global() -@Module({}) -export class TimeoutMiddlewareModule { - static register(options: TimeoutMiddlewareOptions = {}): DynamicModule { - return { - module: TimeoutMiddlewareModule, - providers: [ - { - provide: TIMEOUT_MIDDLEWARE_OPTIONS, - useValue: options, - }, - TimeoutMiddleware, - ], - exports: [TimeoutMiddleware], - }; - } -} diff --git a/middleware/src/middleware/index.ts b/middleware/src/middleware/index.ts deleted file mode 100644 index 93f5841..0000000 --- a/middleware/src/middleware/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './advanced'; diff --git a/middleware/tests/unit/circuit-breaker.middleware.spec.ts b/middleware/tests/unit/circuit-breaker.middleware.spec.ts deleted file mode 100644 index 538e917..0000000 --- a/middleware/tests/unit/circuit-breaker.middleware.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { ServiceUnavailableException } from '@nestjs/common'; -import { - CircuitBreakerMiddleware, - CircuitBreakerService, -} from '../../src/middleware/advanced/circuit-breaker.middleware'; - -describe('CircuitBreakerService', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-03-26T10:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('stays CLOSED until the configured failure threshold is reached', () => { - const service = new CircuitBreakerService({ - name: 'auth-service', - failureThreshold: 3, - halfOpenRetryIntervalMs: 1000, - }); - - service.recordFailure(); - expect(service.getState()).toBe('CLOSED'); - - service.recordFailure(); - expect(service.getState()).toBe('CLOSED'); - - service.recordFailure(); - expect(service.getState()).toBe('OPEN'); - }); - - it('transitions from OPEN to HALF_OPEN after the retry interval', () => { - const service = new CircuitBreakerService({ - name: 'auth-service', - failureThreshold: 2, - halfOpenRetryIntervalMs: 1000, - }); - - service.recordFailure(); - service.recordFailure(); - expect(service.getState()).toBe('OPEN'); - - jest.advanceTimersByTime(999); - expect(service.getState()).toBe('OPEN'); - - jest.advanceTimersByTime(1); - expect(service.getState()).toBe('HALF_OPEN'); - }); - - it('transitions from HALF_OPEN to CLOSED after a successful trial request', () => { - const service = new CircuitBreakerService({ - name: 'auth-service', - failureThreshold: 1, - halfOpenRetryIntervalMs: 1000, - }); - - service.recordFailure(); - expect(service.getState()).toBe('OPEN'); - - jest.advanceTimersByTime(1000); - expect(service.getState()).toBe('HALF_OPEN'); - expect(service.canRequest()).toBe(true); - - service.recordSuccess(); - - expect(service.getState()).toBe('CLOSED'); - expect(service.getSnapshot().failureCount).toBe(0); - }); - - it('transitions from HALF_OPEN back to OPEN when the trial request fails', () => { - const service = new CircuitBreakerService({ - name: 'auth-service', - failureThreshold: 1, - halfOpenRetryIntervalMs: 1000, - }); - - service.recordFailure(); - jest.advanceTimersByTime(1000); - - expect(service.getState()).toBe('HALF_OPEN'); - expect(service.canRequest()).toBe(true); - - service.recordFailure(); - - expect(service.getState()).toBe('OPEN'); - }); - - it('exposes the current circuit state through getSnapshot', () => { - const service = new CircuitBreakerService({ - name: 'auth-service', - failureThreshold: 5, - timeoutWindowMs: 2500, - halfOpenRetryIntervalMs: 7000, - }); - - expect(service.getSnapshot()).toMatchObject({ - name: 'auth-service', - state: 'CLOSED', - failureThreshold: 5, - timeoutWindowMs: 2500, - halfOpenRetryIntervalMs: 7000, - }); - }); -}); - -describe('CircuitBreakerMiddleware', () => { - it('returns 503 while the circuit is OPEN', () => { - const service = new CircuitBreakerService({ - name: 'auth-service', - failureThreshold: 1, - halfOpenRetryIntervalMs: 1000, - }); - const middleware = new CircuitBreakerMiddleware(service); - const next = jest.fn(); - - service.recordFailure(); - - middleware.use( - {} as any, - createResponse(), - next, - ); - - expect(next).toHaveBeenCalledWith(expect.any(ServiceUnavailableException)); - }); -}); - -function createResponse() { - const listeners = new Map void>>(); - - return { - statusCode: 200, - writableEnded: false, - once: jest.fn((event: string, handler: () => void) => { - const current = listeners.get(event) ?? []; - listeners.set(event, [...current, handler]); - }), - removeListener: jest.fn((event: string, handler: () => void) => { - const current = listeners.get(event) ?? []; - listeners.set( - event, - current.filter((candidate) => candidate !== handler), - ); - }), - emit: (event: string) => { - for (const handler of listeners.get(event) ?? []) { - handler(); - } - }, - } as any; -} diff --git a/middleware/tests/unit/timeout.middleware.spec.ts b/middleware/tests/unit/timeout.middleware.spec.ts deleted file mode 100644 index 663005f..0000000 --- a/middleware/tests/unit/timeout.middleware.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ServiceUnavailableException } from '@nestjs/common'; -import { TimeoutMiddleware } from '../../src/middleware/advanced/timeout.middleware'; - -describe('TimeoutMiddleware', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns a 503 error when the timeout threshold is exceeded', () => { - const middleware = new TimeoutMiddleware({ - timeoutMs: 100, - message: 'Middleware execution timed out.', - }); - const response = createResponse(); - const next = jest.fn(); - - middleware.use({} as any, response, next); - - expect(next).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(100); - - expect(next).toHaveBeenLastCalledWith( - expect.any(ServiceUnavailableException), - ); - }); - - it('clears the timeout when the response completes in time', () => { - const middleware = new TimeoutMiddleware({ - timeoutMs: 100, - }); - const response = createResponse(); - const next = jest.fn(); - - middleware.use({} as any, response, next); - response.emit('finish'); - jest.advanceTimersByTime(100); - - expect(next).toHaveBeenCalledTimes(1); - }); -}); - -function createResponse() { - const listeners = new Map void>>(); - - return { - headersSent: false, - once: jest.fn((event: string, handler: () => void) => { - const current = listeners.get(event) ?? []; - listeners.set(event, [...current, handler]); - }), - removeListener: jest.fn((event: string, handler: () => void) => { - const current = listeners.get(event) ?? []; - listeners.set( - event, - current.filter((candidate) => candidate !== handler), - ); - }), - emit: (event: string) => { - for (const handler of listeners.get(event) ?? []) { - handler(); - } - }, - } as any; -}