Skip to content

Commit ce7219f

Browse files
Merge pull request #874 from Alimzy/feat/fingerprint-circuit-breaker
feat(api): add per-endpoint circuit breaker for downstream calls on /api/fingerprint (#673)
2 parents 36c50f8 + fb0405c commit ce7219f

3 files changed

Lines changed: 80 additions & 30 deletions

File tree

src/lib/circuitBreaker.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ export enum CircuitState {
55
}
66

77
export interface CircuitBreakerOptions {
8-
failureThreshold?: number;
9-
resetTimeoutMs?: number;
8+
failureThreshold?: number; // Number of failures before opening
9+
cooldownPeriodMs?: number; // Time in ms before attempting half-open
1010
}
1111

1212
export class CircuitBreakerOpenError extends Error {
13-
constructor(message = 'Service temporarily unavailable due to open circuit breaker') {
13+
public statusCode: number = 503;
14+
constructor(message: string = 'Service unavailable: Circuit breaker is OPEN') {
1415
super(message);
1516
this.name = 'CircuitBreakerOpenError';
1617
}
@@ -23,19 +24,21 @@ export class CircuitBreakerOpenError extends Error {
2324

2425
export class CircuitBreaker {
2526
private state: CircuitState = CircuitState.CLOSED;
26-
private failureCount = 0;
27-
private readonly failureThreshold: number;
28-
private readonly resetTimeoutMs: number;
27+
private failureCount: number = 0;
2928
private lastStateChange: number = Date.now();
29+
private readonly failureThreshold: number;
30+
private readonly cooldownPeriodMs: number;
3031

3132
constructor(options: CircuitBreakerOptions = {}) {
3233
this.failureThreshold = options.failureThreshold ?? 5;
33-
this.resetTimeoutMs = options.resetTimeoutMs ?? 10000;
34+
this.cooldownPeriodMs = options.cooldownPeriodMs ?? 30000; // Default 30 seconds
3435
}
3536

3637
public getState(): CircuitState {
37-
if (this.state === CircuitState.OPEN && Date.now() - this.lastStateChange >= this.resetTimeoutMs) {
38-
this.state = CircuitState.HALF_OPEN;
38+
if (this.state === CircuitState.OPEN) {
39+
if (Date.now() - this.lastStateChange >= this.cooldownPeriodMs) {
40+
this.state = CircuitState.HALF_OPEN;
41+
}
3942
}
4043
return this.state;
4144
}
@@ -60,7 +63,6 @@ export class CircuitBreaker {
6063
private onSuccess(): void {
6164
this.failureCount = 0;
6265
this.state = CircuitState.CLOSED;
63-
this.lastStateChange = Date.now();
6466
}
6567

6668
private onFailure(): void {
@@ -74,6 +76,11 @@ export class CircuitBreaker {
7476
public reset(): void {
7577
this.state = CircuitState.CLOSED;
7678
this.failureCount = 0;
77-
this.lastStateChange = Date.now();
7879
}
7980
}
81+
82+
// Global/Per-endpoint instances
83+
export const fingerprintCircuitBreaker = new CircuitBreaker({
84+
failureThreshold: 3,
85+
cooldownPeriodMs: 15000,
86+
});

src/routes/fingerprint.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,50 @@
1-
import { Router, Request, Response, NextFunction } from 'express';
2-
import { CircuitBreaker, CircuitBreakerOpenError } from '../lib/circuitBreaker';
1+
import { Router, Request, Response } from 'express';
2+
import { fingerprintCircuitBreaker, CircuitBreakerOpenError } from '../lib/circuitBreaker';
33

4-
export const fingerprintCircuitBreaker = new CircuitBreaker({
5-
failureThreshold: 5,
6-
resetTimeoutMs: 10000,
7-
});
8-
9-
export const router = Router();
4+
const router = Router();
105

11-
async function callDownstreamFingerprintService(data: Record<string, unknown>): Promise<Record<string, unknown>> {
12-
// Simulates downstream request execution
13-
return { status: 'success', fingerprintId: 'fp_' + Date.now(), ...data };
6+
/**
7+
* Downstream service simulation / call handler
8+
*/
9+
async function callDownstreamFingerprintService(data: any): Promise<any> {
10+
// Simulates downstream API interaction
11+
return { fingerprintId: 'fp_' + Date.now(), verified: true };
1412
}
1513

16-
router.post('/api/fingerprint', async (req: Request, res: Response, next: NextFunction): Promise<void> => {
14+
/**
15+
* POST /api/fingerprint
16+
*/
17+
router.post('/fingerprint', async (req: Request, res: Response) => {
18+
const correlationId = (req.headers['x-correlation-id'] as string) || `req-${Date.now()}`;
19+
1720
try {
1821
const result = await fingerprintCircuitBreaker.execute(() =>
1922
callDownstreamFingerprintService(req.body)
2023
);
21-
res.status(200).json(result);
22-
} catch (error) {
23-
if (error instanceof CircuitBreakerOpenError) {
24-
res.status(503).json({
24+
25+
return res.status(200).json({
26+
success: true,
27+
data: result,
28+
correlationId,
29+
});
30+
} catch (error: any) {
31+
if (error instanceof CircuitBreakerOpenError || error.statusCode === 503) {
32+
return res.status(503).json({
2533
error: {
2634
code: 'SERVICE_UNAVAILABLE',
27-
message: 'Fingerprint service is temporarily unavailable. Circuit breaker open.',
35+
message: 'Downstream fingerprint service is currently unavailable. Circuit breaker open.',
36+
correlationId,
2837
},
2938
});
30-
return;
3139
}
32-
next(error);
40+
41+
return res.status(500).json({
42+
error: {
43+
code: 'INTERNAL_SERVER_ERROR',
44+
message: error.message || 'An unexpected error occurred.',
45+
correlationId,
46+
},
47+
});
3348
}
3449
});
3550

test/circuitBreaker.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { CircuitBreaker, CircuitState, CircuitBreakerOpenError } from '../src/lib/circuitBreaker';
2+
3+
describe('CircuitBreaker Unit Tests', () => {
4+
let breaker: CircuitBreaker;
5+
6+
beforeEach(() => {
7+
breaker = new CircuitBreaker({ failureThreshold: 2, cooldownPeriodMs: 100 });
8+
});
9+
10+
it('should execute successfully in CLOSED state', async () => {
11+
const fn = jest.fn().mockResolvedValue('ok');
12+
const result = await breaker.execute(fn);
13+
expect(result).toBe('ok');
14+
expect(breaker.getState()).toBe(CircuitState.CLOSED);
15+
});
16+
17+
it('should open breaker after reaching failure threshold', async () => {
18+
const fn = jest.fn().mockRejectedValue(new Error('Downstream failure'));
19+
20+
await expect(breaker.execute(fn)).rejects.toThrow();
21+
await expect(breaker.execute(fn)).rejects.toThrow();
22+
23+
expect(breaker.getState()).toBe(CircuitState.OPEN);
24+
25+
// Should fail fast with 503 CircuitBreakerOpenError without calling inner fn
26+
await expect(breaker.execute(fn)).rejects.toThrow(CircuitBreakerOpenError);
27+
});
28+
});

0 commit comments

Comments
 (0)