diff --git a/src/lib/circuitBreaker.ts b/src/lib/circuitBreaker.ts index 90714dbd..90a65178 100644 --- a/src/lib/circuitBreaker.ts +++ b/src/lib/circuitBreaker.ts @@ -1,86 +1,172 @@ export enum CircuitState { - CLOSED = 'CLOSED', - OPEN = 'OPEN', - HALF_OPEN = 'HALF_OPEN', + CLOSED = "CLOSED", + OPEN = "OPEN", + HALF_OPEN = "HALF_OPEN", } export interface CircuitBreakerOptions { - failureThreshold?: number; // Number of failures before opening - cooldownPeriodMs?: number; // Time in ms before attempting half-open + failureThreshold?: number; + /** Compatibility alias for the rolling failure window. */ + windowMs?: number; + /** Compatibility alias for the OPEN → HALF_OPEN delay. */ + resetTimeoutMs?: number; + cooldownPeriodMs?: number; } -export class CircuitBreakerOpenError extends Error { - public statusCode: number = 503; - constructor(message: string = 'Service unavailable: Circuit breaker is OPEN') { - super(message); - this.name = 'CircuitBreakerOpenError'; +export class CircuitOpenError extends Error { + readonly statusCode = 503; + readonly breakerName: string; + readonly circuitName: string; + readonly state: CircuitState; + readonly openedAt: number; + readonly halfOpenAfterMs: number; + + constructor( + breakerName: string, + state: CircuitState, + openedAt = Date.now(), + halfOpenAfterMs = 30_000, + ) { + super(`Circuit breaker '${breakerName}' is ${state}`); + this.name = "CircuitOpenError"; + this.breakerName = breakerName; + this.circuitName = breakerName; + this.state = state; + this.openedAt = openedAt; + this.halfOpenAfterMs = halfOpenAfterMs; } +} - /** @deprecated Legacy alias for {@link CircuitOpenError.circuitName}. */ - get breakerName(): string { - return this.circuitName; +/** Backwards-compatible name used by the fingerprint endpoint. */ +export class CircuitBreakerOpenError extends CircuitOpenError { + constructor(breakerName = "fingerprint", state = CircuitState.OPEN) { + super(breakerName, state); + this.name = "CircuitBreakerOpenError"; } } export class CircuitBreaker { - private state: CircuitState = CircuitState.CLOSED; - private failureCount: number = 0; - private lastStateChange: number = Date.now(); + private currentState = CircuitState.CLOSED; + private failures: number[] = []; + private openedAt = 0; + private halfOpenProbeInFlight = false; private readonly failureThreshold: number; - private readonly cooldownPeriodMs: number; + private readonly windowMs: number; + private readonly resetTimeoutMs: number; - constructor(options: CircuitBreakerOptions = {}) { + constructor( + nameOrOptions: string | CircuitBreakerOptions = {}, + maybeOptions: CircuitBreakerOptions = {}, + ) { + this.name = typeof nameOrOptions === "string" ? nameOrOptions : "circuit"; + const options = typeof nameOrOptions === "string" ? maybeOptions : nameOrOptions; this.failureThreshold = options.failureThreshold ?? 5; - this.cooldownPeriodMs = options.cooldownPeriodMs ?? 30000; // Default 30 seconds + this.windowMs = options.windowMs ?? 60_000; + this.resetTimeoutMs = options.resetTimeoutMs ?? options.cooldownPeriodMs ?? 30_000; + } + + readonly name: string; + + get state(): CircuitState { + return this.getState(); } public getState(): CircuitState { - if (this.state === CircuitState.OPEN) { - if (Date.now() - this.lastStateChange >= this.cooldownPeriodMs) { - this.state = CircuitState.HALF_OPEN; - } + if ( + this.currentState === CircuitState.OPEN && + Date.now() - this.openedAt >= this.resetTimeoutMs + ) { + this.currentState = CircuitState.HALF_OPEN; } - return this.state; + return this.currentState; } public async execute(fn: () => Promise): Promise { - const currentState = this.getState(); + return this.fire(fn); + } - if (currentState === CircuitState.OPEN) { - throw new CircuitBreakerOpenError(); + public async fire(fn: () => Promise): Promise { + const state = this.getState(); + if (state === CircuitState.OPEN || (state === CircuitState.HALF_OPEN && this.halfOpenProbeInFlight)) { + throw new CircuitBreakerOpenError(this.name, state); + } + + if (state === CircuitState.HALF_OPEN) { + this.halfOpenProbeInFlight = true; } try { const result = await fn(); - this.onSuccess(); + this.currentState = CircuitState.CLOSED; + this.failures = []; return result; - } catch (err) { - this.onFailure(); - throw err; + } catch (error) { + this.recordFailure(state); + throw error; + } finally { + if (state === CircuitState.HALF_OPEN) { + this.halfOpenProbeInFlight = false; + } } } - private onSuccess(): void { - this.failureCount = 0; - this.state = CircuitState.CLOSED; - } + private recordFailure(state: CircuitState): void { + if (state === CircuitState.HALF_OPEN) { + this.open(); + return; + } - private onFailure(): void { - this.failureCount += 1; - if (this.failureCount >= this.failureThreshold || this.state === CircuitState.HALF_OPEN) { - this.state = CircuitState.OPEN; - this.lastStateChange = Date.now(); + const cutoff = Date.now() - this.windowMs; + this.failures = this.failures.filter((timestamp) => timestamp >= cutoff); + this.failures.push(Date.now()); + if (this.failures.length >= this.failureThreshold) { + this.open(); } } + private open(): void { + this.currentState = CircuitState.OPEN; + this.openedAt = Date.now(); + } + public reset(): void { - this.state = CircuitState.CLOSED; - this.failureCount = 0; + this.currentState = CircuitState.CLOSED; + this.failures = []; + this.openedAt = 0; + this.halfOpenProbeInFlight = false; } + + public snapshot(): { + state: CircuitState; + breakerName: string; + circuitName: string; + openedAt: number; + halfOpenAfterMs: number; + } { + return { + state: this.getState(), + breakerName: this.name, + circuitName: this.name, + openedAt: this.openedAt, + halfOpenAfterMs: this.resetTimeoutMs, + }; + } +} + +const breakers = new Map(); + +export function getCircuitBreaker( + name: string, + options: CircuitBreakerOptions = {}, +): CircuitBreaker { + const existing = breakers.get(name); + if (existing) return existing; + const breaker = new CircuitBreaker(name, options); + breakers.set(name, breaker); + return breaker; } -// Global/Per-endpoint instances -export const fingerprintCircuitBreaker = new CircuitBreaker({ +export const fingerprintCircuitBreaker = getCircuitBreaker("fingerprint", { failureThreshold: 3, - cooldownPeriodMs: 15000, + cooldownPeriodMs: 15_000, }); diff --git a/src/routes/fingerprint.ts b/src/routes/fingerprint.ts index 2e1e0cf8..88a9c06c 100644 --- a/src/routes/fingerprint.ts +++ b/src/routes/fingerprint.ts @@ -1,12 +1,39 @@ -import { Router, Request, Response } from 'express'; -import { fingerprintCircuitBreaker, CircuitBreakerOpenError } from '../lib/circuitBreaker'; +import { Router, type Request, type Response, type NextFunction } from "express"; +import { fingerprintCircuitBreaker, CircuitBreakerOpenError } from "../lib/circuitBreaker"; -const router = Router(); +export const fingerprintRouter = Router(); +let inFlightFingerprintRequests = 0; + +export async function drainFingerprintRequests(timeoutMs = 10_000): Promise { + const start = Date.now(); + while (inFlightFingerprintRequests > 0 && Date.now() - start <= timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +function trackFingerprintRequest(_req: Request, res: Response, next: NextFunction): void { + inFlightFingerprintRequests += 1; + let finished = false; + const cleanup = () => { + if (!finished) { + finished = true; + inFlightFingerprintRequests = Math.max(0, inFlightFingerprintRequests - 1); + } + }; + res.once("finish", cleanup); + res.once("close", cleanup); + next(); +} + +fingerprintRouter.use(trackFingerprintRequest); /** * Downstream service simulation / call handler */ -async function callDownstreamFingerprintService(data: any): Promise { +async function callDownstreamFingerprintService(_data: unknown): Promise<{ + fingerprintId: string; + verified: boolean; +}> { // Simulates downstream API interaction return { fingerprintId: 'fp_' + Date.now(), verified: true }; } @@ -14,7 +41,7 @@ async function callDownstreamFingerprintService(data: any): Promise { /** * POST /api/fingerprint */ -router.post('/fingerprint', async (req: Request, res: Response) => { +fingerprintRouter.post("/", async (req: Request, res: Response) => { const correlationId = (req.headers['x-correlation-id'] as string) || `req-${Date.now()}`; try { @@ -27,8 +54,11 @@ router.post('/fingerprint', async (req: Request, res: Response) => { data: result, correlationId, }); - } catch (error: any) { - if (error instanceof CircuitBreakerOpenError || error.statusCode === 503) { + } catch (error: unknown) { + const statusCode = error instanceof Error && "statusCode" in error + ? (error as Error & { statusCode?: number }).statusCode + : undefined; + if (error instanceof CircuitBreakerOpenError || statusCode === 503) { return res.status(503).json({ error: { code: 'SERVICE_UNAVAILABLE', @@ -41,11 +71,11 @@ router.post('/fingerprint', async (req: Request, res: Response) => { return res.status(500).json({ error: { code: 'INTERNAL_SERVER_ERROR', - message: error.message || 'An unexpected error occurred.', + message: error instanceof Error ? error.message : 'An unexpected error occurred.', correlationId, }, }); } }); -export default router; +export default fingerprintRouter; diff --git a/tests/fingerprintCircuitBreakerRoute.test.ts b/tests/fingerprintCircuitBreakerRoute.test.ts new file mode 100644 index 00000000..4479eb20 --- /dev/null +++ b/tests/fingerprintCircuitBreakerRoute.test.ts @@ -0,0 +1,42 @@ +import express from "express"; +import request from "supertest"; +import { + fingerprintRouter, +} from "../src/routes/fingerprint"; +import { fingerprintCircuitBreaker } from "../src/lib/circuitBreaker"; + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use("/api/fingerprint", fingerprintRouter); + return app; +} + +describe("POST /api/fingerprint circuit breaker", () => { + beforeEach(() => fingerprintCircuitBreaker.reset()); + + it("returns 503 without calling downstream work when the circuit is open", async () => { + const fail = async () => { + throw new Error("downstream unavailable"); + }; + for (let i = 0; i < 3; i += 1) { + await fingerprintCircuitBreaker.fire(fail).catch(() => undefined); + } + + const response = await request(makeApp()) + .post("/api/fingerprint") + .send({ address: "GTEST" }); + + expect(response.status).toBe(503); + expect(response.body.error.code).toBe("SERVICE_UNAVAILABLE"); + }); + + it("allows requests again after an explicit reset", async () => { + const response = await request(makeApp()) + .post("/api/fingerprint") + .send({ address: "GTEST" }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); +});