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
176 changes: 131 additions & 45 deletions src/lib/circuitBreaker.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
const currentState = this.getState();
return this.fire(fn);
}

if (currentState === CircuitState.OPEN) {
throw new CircuitBreakerOpenError();
public async fire<T>(fn: () => Promise<T>): Promise<T> {
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<string, CircuitBreaker>();

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,
});
48 changes: 39 additions & 9 deletions src/routes/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,47 @@
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<void> {
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<any> {
async function callDownstreamFingerprintService(_data: unknown): Promise<{
fingerprintId: string;
verified: boolean;
}> {
// Simulates downstream API interaction
return { fingerprintId: 'fp_' + Date.now(), verified: true };
}

/**
* 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 {
Expand All @@ -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',
Expand All @@ -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;
42 changes: 42 additions & 0 deletions tests/fingerprintCircuitBreakerRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading