Skip to content

Commit 463e230

Browse files
Merge pull request #1012 from eogenyi23-creator/feature/924-per-endpoint-circuit-breaker
feat(gateway): add per-endpoint circuit breaker for /api/gateway (#924)
2 parents 7c29e7c + 4e509b1 commit 463e230

5 files changed

Lines changed: 414 additions & 7 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ BCRYPT_COST_FACTOR=12
6666
# -----------------------------------------------------------------------------
6767
UPSTREAM_URL=http://localhost:4000
6868
PROXY_TIMEOUT_MS=30000
69+
70+
# Per-endpoint circuit breaker for /api/gateway downstream calls.
71+
# Each API endpoint gets its own breaker keyed by apiId. When the breaker
72+
# trips (OPEN state), gateway requests return 503 immediately without
73+
# attempting the upstream call.
74+
GATEWAY_BREAKER_FAILURE_THRESHOLD=5
75+
GATEWAY_BREAKER_COOLDOWN_MS=30000
76+
GATEWAY_BREAKER_SUCCESS_THRESHOLD=1
77+
6978
REST_RATE_LIMIT_WINDOW_MS=60000
7079
REST_RATE_LIMIT_MAX_REQUESTS=100
7180
WEBHOOK_SECRET_ROTATION_GRACE_MS=86400000

src/config/env.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,23 @@ export const envSchema = z
9999
.int()
100100
.positive()
101101
.default(1),
102+
// Per-endpoint circuit breaker config for /api/gateway downstream calls.
103+
// Each API endpoint gets its own breaker keyed by apiId.
104+
GATEWAY_BREAKER_FAILURE_THRESHOLD: z.coerce
105+
.number()
106+
.int()
107+
.positive()
108+
.default(5),
109+
GATEWAY_BREAKER_COOLDOWN_MS: z.coerce
110+
.number()
111+
.int()
112+
.positive()
113+
.default(30_000),
114+
GATEWAY_BREAKER_SUCCESS_THRESHOLD: z.coerce
115+
.number()
116+
.int()
117+
.positive()
118+
.default(1),
102119
REST_RATE_LIMIT_WINDOW_MS: z.coerce
103120
.number()
104121
.int()

src/lib/circuitBreaker.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,28 @@ export class CircuitBreaker {
419419
return metrics.state;
420420
}
421421

422+
/**
423+
* Returns true if the breaker would currently reject a call (i.e. the circuit
424+
* is OPEN and still within its cooldown window).
425+
*
426+
* This mirrors the rejection logic in execute() so callers can perform a
427+
* pre-flight check — for example, to skip billing before attempting a call
428+
* that is guaranteed to be rejected.
429+
*
430+
* When this returns false the call MAY succeed: the breaker is either CLOSED,
431+
* HALF_OPEN, or OPEN but past its cooldown (meaning execute() will transition
432+
* to HALF_OPEN and allow a probe).
433+
*/
434+
async wouldBlock(breakerKey: string): Promise<boolean> {
435+
const now = Date.now();
436+
const metrics = (await this.store.get(breakerKey)) || { ...DEFAULT_METRICS };
437+
if (metrics.state !== CircuitBreakerState.OPEN) {
438+
return false;
439+
}
440+
const timeSinceFailure = now - (metrics.lastFailureTime ?? 0);
441+
return timeSinceFailure < this.config.cooldownMs;
442+
}
443+
422444
/**
423445
* Force reset the circuit breaker to CLOSED state.
424446
* Use with caution - primarily for testing or manual intervention.
@@ -452,6 +474,7 @@ export class CircuitBreaker {
452474
state: CircuitBreakerState.OPEN,
453475
consecutiveSuccesses: 0,
454476
lastStateChange: now,
477+
lastFailureTime: now,
455478
};
456479
await this.store.set(breakerKey, newMetrics);
457480
this.activeTrials.delete(breakerKey);
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
/**
2+
* Tests for issue #924 — per-endpoint circuit breaker on /api/gateway
3+
*
4+
* Verifies that:
5+
* - Each apiId gets an isolated circuit breaker (failures on api-A don't trip api-B)
6+
* - A tripped breaker returns 503 before billing is charged
7+
* - Upstream errors trip the breaker after failureThreshold failures
8+
* - The breaker returns 503 when in OPEN state (fast-fail)
9+
* - The HALF_OPEN state allows a single probe and re-closes on success
10+
* - Billing is never charged when the circuit is OPEN
11+
*/
12+
13+
import express from 'express';
14+
import request from 'supertest';
15+
import { createGatewayRouter, clearHealthCache } from './gatewayRoutes.js';
16+
import { errorHandler } from '../middleware/errorHandler.js';
17+
import { requestIdMiddleware } from '../middleware/requestId.js';
18+
import {
19+
BreakerRegistry,
20+
CircuitBreakerState,
21+
} from '../lib/circuitBreaker.js';
22+
import type { GatewayDeps } from '../types/gateway.js';
23+
24+
// ---------------------------------------------------------------------------
25+
// Constants
26+
// ---------------------------------------------------------------------------
27+
28+
const API_ID_A = 'api-cb-a';
29+
const API_ID_B = 'api-cb-b';
30+
// Keys must be ≥ 16 chars for prefix-based lookup in gatewayRoutes
31+
const API_KEY_A = 'circuit-key-a-xxxxxyz';
32+
const API_KEY_B = 'circuit-key-b-xxxxxyz';
33+
34+
// ---------------------------------------------------------------------------
35+
// Helpers
36+
// ---------------------------------------------------------------------------
37+
38+
function makeApiKeys() {
39+
return new Map([
40+
[API_KEY_A, { key: 'ka', apiId: API_ID_A, developerId: 'devA' }],
41+
[API_KEY_B, { key: 'kb', apiId: API_ID_B, developerId: 'devB' }],
42+
]);
43+
}
44+
45+
function buildApp(
46+
breakerRegistry: BreakerRegistry,
47+
billingMock: jest.Mock = jest.fn().mockResolvedValue({ success: true, balance: 100 }),
48+
): express.Application {
49+
const deps: GatewayDeps = {
50+
billing: {
51+
deductCredit: billingMock,
52+
checkBalance: async () => 100,
53+
},
54+
rateLimiter: { check: async () => ({ allowed: true }) },
55+
usageStore: {
56+
record: jest.fn().mockResolvedValue(true),
57+
hasEvent: jest.fn(),
58+
getEvents: jest.fn(),
59+
getUnsettledEvents: jest.fn(),
60+
markAsSettled: jest.fn(),
61+
},
62+
upstreamUrl: 'http://example.internal',
63+
apiKeys: makeApiKeys(),
64+
breakerRegistry,
65+
};
66+
67+
const app = express();
68+
app.use(requestIdMiddleware);
69+
app.use('/api/gateway', createGatewayRouter(deps));
70+
app.use(errorHandler);
71+
return app;
72+
}
73+
74+
// ---------------------------------------------------------------------------
75+
// Tests
76+
// ---------------------------------------------------------------------------
77+
78+
describe('gateway per-endpoint circuit breaker (#924)', () => {
79+
let savedFetch: typeof global.fetch;
80+
81+
beforeAll(() => {
82+
savedFetch = global.fetch;
83+
});
84+
85+
afterEach(() => {
86+
global.fetch = savedFetch;
87+
clearHealthCache();
88+
jest.restoreAllMocks();
89+
});
90+
91+
// ── Happy-path: breaker stays CLOSED on success ─────────────────────────
92+
93+
it('forwards requests to upstream when the breaker is CLOSED', async () => {
94+
global.fetch = jest.fn().mockResolvedValue({
95+
status: 200,
96+
headers: new Headers({ 'content-type': 'application/json' }),
97+
text: async () => JSON.stringify({ hello: 'world' }),
98+
} as Response);
99+
100+
const registry = new BreakerRegistry();
101+
const app = buildApp(registry);
102+
103+
const res = await request(app)
104+
.get(`/api/gateway/${API_ID_A}`)
105+
.set('x-api-key', API_KEY_A);
106+
107+
expect(res.status).toBe(200);
108+
expect(global.fetch).toHaveBeenCalledTimes(1);
109+
});
110+
111+
// ── Manual trip → 503, no upstream call, no billing ─────────────────────
112+
113+
it('returns 503 and skips upstream + billing when breaker is manually tripped', async () => {
114+
global.fetch = jest.fn().mockResolvedValue({
115+
status: 200,
116+
headers: new Headers({ 'content-type': 'application/json' }),
117+
text: async () => '{}',
118+
} as Response);
119+
120+
const registry = new BreakerRegistry();
121+
// Create breaker with very long cooldown so it stays OPEN
122+
const breaker = registry.getOrCreate(API_ID_A, {
123+
failureThreshold: 1,
124+
cooldownMs: 999_999,
125+
});
126+
await breaker.trip(API_ID_A);
127+
128+
const billingMock = jest.fn().mockResolvedValue({ success: true, balance: 100 });
129+
const app = buildApp(registry, billingMock);
130+
131+
const res = await request(app)
132+
.get(`/api/gateway/${API_ID_A}`)
133+
.set('x-api-key', API_KEY_A);
134+
135+
expect(res.status).toBe(503);
136+
137+
// Upstream must NOT be called
138+
expect(global.fetch).not.toHaveBeenCalled();
139+
// Billing must NOT be charged
140+
expect(billingMock).not.toHaveBeenCalled();
141+
});
142+
143+
// ── 503 response has the correct error envelope ──────────────────────────
144+
145+
it('503 response has the standard SERVICE_UNAVAILABLE error code', async () => {
146+
const registry = new BreakerRegistry();
147+
const breaker = registry.getOrCreate(API_ID_A, {
148+
failureThreshold: 1,
149+
cooldownMs: 999_999,
150+
});
151+
await breaker.trip(API_ID_A);
152+
153+
const app = buildApp(registry);
154+
155+
const res = await request(app)
156+
.get(`/api/gateway/${API_ID_A}`)
157+
.set('x-api-key', API_KEY_A);
158+
159+
expect(res.status).toBe(503);
160+
expect(res.headers['content-type']).toMatch(/application\/json/);
161+
// Support both flat {code, message} and nested {error: {code, message}}
162+
const code: string = res.body.error?.code ?? res.body.code;
163+
const message: string = res.body.error?.message ?? res.body.message;
164+
expect(code).toBe('SERVICE_UNAVAILABLE');
165+
expect(message).toMatch(/circuit breaker/i);
166+
});
167+
168+
// ── Upstream failures trip the breaker ───────────────────────────────────
169+
170+
it('trips the breaker after failureThreshold upstream errors', async () => {
171+
const FAILURE_THRESHOLD = 3;
172+
173+
// Upstream rejects every time (TypeError with network error)
174+
global.fetch = jest.fn().mockRejectedValue(
175+
new TypeError('fetch failed'),
176+
);
177+
178+
const registry = new BreakerRegistry();
179+
// Pre-create breaker with known config
180+
registry.getOrCreate(API_ID_A, {
181+
failureThreshold: FAILURE_THRESHOLD,
182+
cooldownMs: 999_999,
183+
});
184+
const billingMock = jest.fn().mockResolvedValue({ success: true, balance: 100 });
185+
const app = buildApp(registry, billingMock);
186+
187+
// Fire FAILURE_THRESHOLD requests — each gets a 502
188+
for (let i = 0; i < FAILURE_THRESHOLD; i++) {
189+
const res = await request(app)
190+
.get(`/api/gateway/${API_ID_A}`)
191+
.set('x-api-key', API_KEY_A);
192+
expect([502, 503]).toContain(res.status);
193+
}
194+
195+
// After threshold failures the breaker should be OPEN
196+
const state = await registry.getState(API_ID_A);
197+
expect(state).toBe(CircuitBreakerState.OPEN);
198+
});
199+
200+
// ── Next request after trip returns 503, no billing ──────────────────────
201+
202+
it('returns 503 and does not call billing once breaker is OPEN', async () => {
203+
const FAILURE_THRESHOLD = 2;
204+
205+
global.fetch = jest.fn().mockRejectedValue(new TypeError('fetch failed'));
206+
207+
const registry = new BreakerRegistry();
208+
registry.getOrCreate(API_ID_A, {
209+
failureThreshold: FAILURE_THRESHOLD,
210+
cooldownMs: 999_999,
211+
});
212+
const billingMock = jest.fn().mockResolvedValue({ success: true, balance: 100 });
213+
const app = buildApp(registry, billingMock);
214+
215+
// Exhaust the threshold
216+
for (let i = 0; i < FAILURE_THRESHOLD; i++) {
217+
await request(app)
218+
.get(`/api/gateway/${API_ID_A}`)
219+
.set('x-api-key', API_KEY_A);
220+
}
221+
222+
// Breaker is now OPEN
223+
expect(await registry.getState(API_ID_A)).toBe(CircuitBreakerState.OPEN);
224+
225+
billingMock.mockClear();
226+
(global.fetch as jest.Mock).mockClear();
227+
228+
const res = await request(app)
229+
.get(`/api/gateway/${API_ID_A}`)
230+
.set('x-api-key', API_KEY_A);
231+
232+
expect(res.status).toBe(503);
233+
// After the breaker opens, billing and upstream must not be touched
234+
expect(billingMock).not.toHaveBeenCalled();
235+
expect(global.fetch).not.toHaveBeenCalled();
236+
});
237+
238+
// ── Isolation: failures on api-A do NOT trip api-B ─────────────────────
239+
240+
it('breaker isolation — failures on api-A do not affect api-B', async () => {
241+
const FAILURE_THRESHOLD = 2;
242+
243+
const fetchMock = jest.fn()
244+
// First N calls for api-A fail
245+
.mockRejectedValueOnce(new TypeError('fetch failed'))
246+
.mockRejectedValueOnce(new TypeError('fetch failed'))
247+
// api-B call succeeds
248+
.mockResolvedValue({
249+
status: 200,
250+
headers: new Headers({ 'content-type': 'application/json' }),
251+
text: async () => JSON.stringify({ ok: true }),
252+
} as Response);
253+
254+
global.fetch = fetchMock;
255+
256+
const registry = new BreakerRegistry();
257+
registry.getOrCreate(API_ID_A, { failureThreshold: FAILURE_THRESHOLD, cooldownMs: 999_999 });
258+
registry.getOrCreate(API_ID_B, { failureThreshold: FAILURE_THRESHOLD, cooldownMs: 999_999 });
259+
const app = buildApp(registry);
260+
261+
// Trip api-A's breaker
262+
for (let i = 0; i < FAILURE_THRESHOLD; i++) {
263+
await request(app)
264+
.get(`/api/gateway/${API_ID_A}`)
265+
.set('x-api-key', API_KEY_A);
266+
}
267+
268+
expect(await registry.getState(API_ID_A)).toBe(CircuitBreakerState.OPEN);
269+
270+
// api-B must still be operational
271+
const res = await request(app)
272+
.get(`/api/gateway/${API_ID_B}`)
273+
.set('x-api-key', API_KEY_B);
274+
275+
expect(res.status).toBe(200);
276+
expect(await registry.getState(API_ID_B)).toBe(CircuitBreakerState.CLOSED);
277+
});
278+
279+
// ── HALF_OPEN probe: successful probe re-closes the breaker ──────────────
280+
281+
it('re-closes breaker after a successful probe in HALF_OPEN state', async () => {
282+
const COOLDOWN_MS = 50; // very short for test speed
283+
284+
global.fetch = jest.fn().mockResolvedValue({
285+
status: 200,
286+
headers: new Headers({ 'content-type': 'application/json' }),
287+
text: async () => JSON.stringify({ recovered: true }),
288+
} as Response);
289+
290+
const registry = new BreakerRegistry();
291+
const breaker = registry.getOrCreate(API_ID_A, {
292+
failureThreshold: 1,
293+
cooldownMs: COOLDOWN_MS,
294+
successThreshold: 1,
295+
});
296+
297+
// Trip the breaker with a failure timestamp
298+
await breaker.trip(API_ID_A);
299+
expect(await registry.getState(API_ID_A)).toBe(CircuitBreakerState.OPEN);
300+
301+
// Wait for the cooldown to elapse — execute() will move to HALF_OPEN
302+
await new Promise<void>((r) => setTimeout(r, COOLDOWN_MS + 20));
303+
304+
const app = buildApp(registry);
305+
306+
// The probe request goes through (execute() transitions OPEN→HALF_OPEN,
307+
// then HALF_OPEN→CLOSED on success)
308+
const res = await request(app)
309+
.get(`/api/gateway/${API_ID_A}`)
310+
.set('x-api-key', API_KEY_A);
311+
312+
expect(res.status).toBe(200);
313+
expect(await registry.getState(API_ID_A)).toBe(CircuitBreakerState.CLOSED);
314+
});
315+
});

0 commit comments

Comments
 (0)