Skip to content

Commit dcadc90

Browse files
Merge pull request #885 from 1nonlypiece/feat/fingerprint-circuit-breaker-fix
fix(fingerprint): enforce endpoint circuit breaker
2 parents 2502a92 + 9944451 commit dcadc90

3 files changed

Lines changed: 212 additions & 54 deletions

File tree

src/lib/circuitBreaker.ts

Lines changed: 131 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,172 @@
11
export enum CircuitState {
2-
CLOSED = 'CLOSED',
3-
OPEN = 'OPEN',
4-
HALF_OPEN = 'HALF_OPEN',
2+
CLOSED = "CLOSED",
3+
OPEN = "OPEN",
4+
HALF_OPEN = "HALF_OPEN",
55
}
66

77
export interface CircuitBreakerOptions {
8-
failureThreshold?: number; // Number of failures before opening
9-
cooldownPeriodMs?: number; // Time in ms before attempting half-open
8+
failureThreshold?: number;
9+
/** Compatibility alias for the rolling failure window. */
10+
windowMs?: number;
11+
/** Compatibility alias for the OPEN → HALF_OPEN delay. */
12+
resetTimeoutMs?: number;
13+
cooldownPeriodMs?: number;
1014
}
1115

12-
export class CircuitBreakerOpenError extends Error {
13-
public statusCode: number = 503;
14-
constructor(message: string = 'Service unavailable: Circuit breaker is OPEN') {
15-
super(message);
16-
this.name = 'CircuitBreakerOpenError';
16+
export class CircuitOpenError extends Error {
17+
readonly statusCode = 503;
18+
readonly breakerName: string;
19+
readonly circuitName: string;
20+
readonly state: CircuitState;
21+
readonly openedAt: number;
22+
readonly halfOpenAfterMs: number;
23+
24+
constructor(
25+
breakerName: string,
26+
state: CircuitState,
27+
openedAt = Date.now(),
28+
halfOpenAfterMs = 30_000,
29+
) {
30+
super(`Circuit breaker '${breakerName}' is ${state}`);
31+
this.name = "CircuitOpenError";
32+
this.breakerName = breakerName;
33+
this.circuitName = breakerName;
34+
this.state = state;
35+
this.openedAt = openedAt;
36+
this.halfOpenAfterMs = halfOpenAfterMs;
1737
}
38+
}
1839

19-
/** @deprecated Legacy alias for {@link CircuitOpenError.circuitName}. */
20-
get breakerName(): string {
21-
return this.circuitName;
40+
/** Backwards-compatible name used by the fingerprint endpoint. */
41+
export class CircuitBreakerOpenError extends CircuitOpenError {
42+
constructor(breakerName = "fingerprint", state = CircuitState.OPEN) {
43+
super(breakerName, state);
44+
this.name = "CircuitBreakerOpenError";
2245
}
2346
}
2447

2548
export class CircuitBreaker {
26-
private state: CircuitState = CircuitState.CLOSED;
27-
private failureCount: number = 0;
28-
private lastStateChange: number = Date.now();
49+
private currentState = CircuitState.CLOSED;
50+
private failures: number[] = [];
51+
private openedAt = 0;
52+
private halfOpenProbeInFlight = false;
2953
private readonly failureThreshold: number;
30-
private readonly cooldownPeriodMs: number;
54+
private readonly windowMs: number;
55+
private readonly resetTimeoutMs: number;
3156

32-
constructor(options: CircuitBreakerOptions = {}) {
57+
constructor(
58+
nameOrOptions: string | CircuitBreakerOptions = {},
59+
maybeOptions: CircuitBreakerOptions = {},
60+
) {
61+
this.name = typeof nameOrOptions === "string" ? nameOrOptions : "circuit";
62+
const options = typeof nameOrOptions === "string" ? maybeOptions : nameOrOptions;
3363
this.failureThreshold = options.failureThreshold ?? 5;
34-
this.cooldownPeriodMs = options.cooldownPeriodMs ?? 30000; // Default 30 seconds
64+
this.windowMs = options.windowMs ?? 60_000;
65+
this.resetTimeoutMs = options.resetTimeoutMs ?? options.cooldownPeriodMs ?? 30_000;
66+
}
67+
68+
readonly name: string;
69+
70+
get state(): CircuitState {
71+
return this.getState();
3572
}
3673

3774
public getState(): CircuitState {
38-
if (this.state === CircuitState.OPEN) {
39-
if (Date.now() - this.lastStateChange >= this.cooldownPeriodMs) {
40-
this.state = CircuitState.HALF_OPEN;
41-
}
75+
if (
76+
this.currentState === CircuitState.OPEN &&
77+
Date.now() - this.openedAt >= this.resetTimeoutMs
78+
) {
79+
this.currentState = CircuitState.HALF_OPEN;
4280
}
43-
return this.state;
81+
return this.currentState;
4482
}
4583

4684
public async execute<T>(fn: () => Promise<T>): Promise<T> {
47-
const currentState = this.getState();
85+
return this.fire(fn);
86+
}
4887

49-
if (currentState === CircuitState.OPEN) {
50-
throw new CircuitBreakerOpenError();
88+
public async fire<T>(fn: () => Promise<T>): Promise<T> {
89+
const state = this.getState();
90+
if (state === CircuitState.OPEN || (state === CircuitState.HALF_OPEN && this.halfOpenProbeInFlight)) {
91+
throw new CircuitBreakerOpenError(this.name, state);
92+
}
93+
94+
if (state === CircuitState.HALF_OPEN) {
95+
this.halfOpenProbeInFlight = true;
5196
}
5297

5398
try {
5499
const result = await fn();
55-
this.onSuccess();
100+
this.currentState = CircuitState.CLOSED;
101+
this.failures = [];
56102
return result;
57-
} catch (err) {
58-
this.onFailure();
59-
throw err;
103+
} catch (error) {
104+
this.recordFailure(state);
105+
throw error;
106+
} finally {
107+
if (state === CircuitState.HALF_OPEN) {
108+
this.halfOpenProbeInFlight = false;
109+
}
60110
}
61111
}
62112

63-
private onSuccess(): void {
64-
this.failureCount = 0;
65-
this.state = CircuitState.CLOSED;
66-
}
113+
private recordFailure(state: CircuitState): void {
114+
if (state === CircuitState.HALF_OPEN) {
115+
this.open();
116+
return;
117+
}
67118

68-
private onFailure(): void {
69-
this.failureCount += 1;
70-
if (this.failureCount >= this.failureThreshold || this.state === CircuitState.HALF_OPEN) {
71-
this.state = CircuitState.OPEN;
72-
this.lastStateChange = Date.now();
119+
const cutoff = Date.now() - this.windowMs;
120+
this.failures = this.failures.filter((timestamp) => timestamp >= cutoff);
121+
this.failures.push(Date.now());
122+
if (this.failures.length >= this.failureThreshold) {
123+
this.open();
73124
}
74125
}
75126

127+
private open(): void {
128+
this.currentState = CircuitState.OPEN;
129+
this.openedAt = Date.now();
130+
}
131+
76132
public reset(): void {
77-
this.state = CircuitState.CLOSED;
78-
this.failureCount = 0;
133+
this.currentState = CircuitState.CLOSED;
134+
this.failures = [];
135+
this.openedAt = 0;
136+
this.halfOpenProbeInFlight = false;
79137
}
138+
139+
public snapshot(): {
140+
state: CircuitState;
141+
breakerName: string;
142+
circuitName: string;
143+
openedAt: number;
144+
halfOpenAfterMs: number;
145+
} {
146+
return {
147+
state: this.getState(),
148+
breakerName: this.name,
149+
circuitName: this.name,
150+
openedAt: this.openedAt,
151+
halfOpenAfterMs: this.resetTimeoutMs,
152+
};
153+
}
154+
}
155+
156+
const breakers = new Map<string, CircuitBreaker>();
157+
158+
export function getCircuitBreaker(
159+
name: string,
160+
options: CircuitBreakerOptions = {},
161+
): CircuitBreaker {
162+
const existing = breakers.get(name);
163+
if (existing) return existing;
164+
const breaker = new CircuitBreaker(name, options);
165+
breakers.set(name, breaker);
166+
return breaker;
80167
}
81168

82-
// Global/Per-endpoint instances
83-
export const fingerprintCircuitBreaker = new CircuitBreaker({
169+
export const fingerprintCircuitBreaker = getCircuitBreaker("fingerprint", {
84170
failureThreshold: 3,
85-
cooldownPeriodMs: 15000,
171+
cooldownPeriodMs: 15_000,
86172
});

src/routes/fingerprint.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,47 @@
1-
import { Router, Request, Response } from 'express';
2-
import { fingerprintCircuitBreaker, CircuitBreakerOpenError } from '../lib/circuitBreaker';
1+
import { Router, type Request, type Response, type NextFunction } from "express";
2+
import { fingerprintCircuitBreaker, CircuitBreakerOpenError } from "../lib/circuitBreaker";
33

4-
const router = Router();
4+
export const fingerprintRouter = Router();
5+
let inFlightFingerprintRequests = 0;
6+
7+
export async function drainFingerprintRequests(timeoutMs = 10_000): Promise<void> {
8+
const start = Date.now();
9+
while (inFlightFingerprintRequests > 0 && Date.now() - start <= timeoutMs) {
10+
await new Promise((resolve) => setTimeout(resolve, 50));
11+
}
12+
}
13+
14+
function trackFingerprintRequest(_req: Request, res: Response, next: NextFunction): void {
15+
inFlightFingerprintRequests += 1;
16+
let finished = false;
17+
const cleanup = () => {
18+
if (!finished) {
19+
finished = true;
20+
inFlightFingerprintRequests = Math.max(0, inFlightFingerprintRequests - 1);
21+
}
22+
};
23+
res.once("finish", cleanup);
24+
res.once("close", cleanup);
25+
next();
26+
}
27+
28+
fingerprintRouter.use(trackFingerprintRequest);
529

630
/**
731
* Downstream service simulation / call handler
832
*/
9-
async function callDownstreamFingerprintService(data: any): Promise<any> {
33+
async function callDownstreamFingerprintService(_data: unknown): Promise<{
34+
fingerprintId: string;
35+
verified: boolean;
36+
}> {
1037
// Simulates downstream API interaction
1138
return { fingerprintId: 'fp_' + Date.now(), verified: true };
1239
}
1340

1441
/**
1542
* POST /api/fingerprint
1643
*/
17-
router.post('/fingerprint', async (req: Request, res: Response) => {
44+
fingerprintRouter.post("/", async (req: Request, res: Response) => {
1845
const correlationId = (req.headers['x-correlation-id'] as string) || `req-${Date.now()}`;
1946

2047
try {
@@ -27,8 +54,11 @@ router.post('/fingerprint', async (req: Request, res: Response) => {
2754
data: result,
2855
correlationId,
2956
});
30-
} catch (error: any) {
31-
if (error instanceof CircuitBreakerOpenError || error.statusCode === 503) {
57+
} catch (error: unknown) {
58+
const statusCode = error instanceof Error && "statusCode" in error
59+
? (error as Error & { statusCode?: number }).statusCode
60+
: undefined;
61+
if (error instanceof CircuitBreakerOpenError || statusCode === 503) {
3262
return res.status(503).json({
3363
error: {
3464
code: 'SERVICE_UNAVAILABLE',
@@ -41,11 +71,11 @@ router.post('/fingerprint', async (req: Request, res: Response) => {
4171
return res.status(500).json({
4272
error: {
4373
code: 'INTERNAL_SERVER_ERROR',
44-
message: error.message || 'An unexpected error occurred.',
74+
message: error instanceof Error ? error.message : 'An unexpected error occurred.',
4575
correlationId,
4676
},
4777
});
4878
}
4979
});
5080

51-
export default router;
81+
export default fingerprintRouter;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import express from "express";
2+
import request from "supertest";
3+
import {
4+
fingerprintRouter,
5+
} from "../src/routes/fingerprint";
6+
import { fingerprintCircuitBreaker } from "../src/lib/circuitBreaker";
7+
8+
function makeApp() {
9+
const app = express();
10+
app.use(express.json());
11+
app.use("/api/fingerprint", fingerprintRouter);
12+
return app;
13+
}
14+
15+
describe("POST /api/fingerprint circuit breaker", () => {
16+
beforeEach(() => fingerprintCircuitBreaker.reset());
17+
18+
it("returns 503 without calling downstream work when the circuit is open", async () => {
19+
const fail = async () => {
20+
throw new Error("downstream unavailable");
21+
};
22+
for (let i = 0; i < 3; i += 1) {
23+
await fingerprintCircuitBreaker.fire(fail).catch(() => undefined);
24+
}
25+
26+
const response = await request(makeApp())
27+
.post("/api/fingerprint")
28+
.send({ address: "GTEST" });
29+
30+
expect(response.status).toBe(503);
31+
expect(response.body.error.code).toBe("SERVICE_UNAVAILABLE");
32+
});
33+
34+
it("allows requests again after an explicit reset", async () => {
35+
const response = await request(makeApp())
36+
.post("/api/fingerprint")
37+
.send({ address: "GTEST" });
38+
39+
expect(response.status).toBe(200);
40+
expect(response.body.success).toBe(true);
41+
});
42+
});

0 commit comments

Comments
 (0)