Skip to content

Commit f0cd1a7

Browse files
author
StackMemory Bot (CLI)
committed
test(coverage): add tests for recovery patterns (circuit breaker, bulkhead, fallback)
23 new tests covering: - calculateBackoff: exponential growth, jitter, max cap - CircuitBreaker: state transitions (CLOSED→OPEN→HALF_OPEN→CLOSED), threshold, reset - Bulkhead: concurrency limiting, queueing, error handling - withFallback: primary/fallback chain, all-fail case - withTimeout: success, timeout rejection, custom message - gracefulDegrade: success passthrough, default on failure
1 parent b8679b7 commit f0cd1a7

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import {
3+
calculateBackoff,
4+
CircuitBreaker,
5+
CircuitState,
6+
Bulkhead,
7+
withFallback,
8+
withTimeout,
9+
gracefulDegrade,
10+
} from '../recovery.js';
11+
12+
describe('recovery utilities', () => {
13+
describe('calculateBackoff', () => {
14+
it('returns initial delay on first attempt', () => {
15+
// With jitter, should be between initialDelay and initialDelay * 1.25
16+
const delay = calculateBackoff(1, 1000, 30000, 2);
17+
expect(delay).toBeGreaterThanOrEqual(1000);
18+
expect(delay).toBeLessThanOrEqual(1250);
19+
});
20+
21+
it('increases exponentially', () => {
22+
const d1 = calculateBackoff(1, 100, 100000, 2);
23+
const d2 = calculateBackoff(2, 100, 100000, 2);
24+
const d3 = calculateBackoff(3, 100, 100000, 2);
25+
// Approximate: 100, 200, 400 + jitter
26+
expect(d2).toBeGreaterThan(d1);
27+
expect(d3).toBeGreaterThan(d2);
28+
});
29+
30+
it('caps at maxDelay', () => {
31+
const delay = calculateBackoff(20, 1000, 5000, 2);
32+
expect(delay).toBeLessThanOrEqual(6250); // 5000 + 25% jitter
33+
});
34+
});
35+
36+
describe('CircuitBreaker', () => {
37+
it('starts in CLOSED state', () => {
38+
const cb = new CircuitBreaker('test');
39+
expect(cb.getState()).toBe(CircuitState.CLOSED);
40+
});
41+
42+
it('stays CLOSED on success', async () => {
43+
const cb = new CircuitBreaker('test');
44+
await cb.execute(async () => 'ok');
45+
expect(cb.getState()).toBe(CircuitState.CLOSED);
46+
});
47+
48+
it('opens after reaching failure threshold', async () => {
49+
const cb = new CircuitBreaker('test', { failureThreshold: 3 });
50+
51+
for (let i = 0; i < 3; i++) {
52+
await cb
53+
.execute(async () => {
54+
throw new Error('fail');
55+
})
56+
.catch(() => {});
57+
}
58+
59+
expect(cb.getState()).toBe(CircuitState.OPEN);
60+
});
61+
62+
it('rejects calls when OPEN', async () => {
63+
const cb = new CircuitBreaker('test', {
64+
failureThreshold: 1,
65+
resetTimeout: 60000,
66+
});
67+
68+
await cb
69+
.execute(async () => {
70+
throw new Error('fail');
71+
})
72+
.catch(() => {});
73+
74+
await expect(cb.execute(async () => 'ok')).rejects.toThrow(
75+
/Circuit breaker test is OPEN/
76+
);
77+
});
78+
79+
it('transitions to HALF_OPEN after reset timeout', async () => {
80+
const cb = new CircuitBreaker('test', {
81+
failureThreshold: 1,
82+
resetTimeout: 50,
83+
});
84+
85+
await cb
86+
.execute(async () => {
87+
throw new Error('fail');
88+
})
89+
.catch(() => {});
90+
91+
expect(cb.getState()).toBe(CircuitState.OPEN);
92+
93+
await new Promise((r) => setTimeout(r, 60));
94+
95+
// Next call should transition to HALF_OPEN and succeed
96+
await cb.execute(async () => 'ok');
97+
// Should be in HALF_OPEN or CLOSED depending on halfOpenRequests
98+
});
99+
100+
it('reset() returns to CLOSED', async () => {
101+
const cb = new CircuitBreaker('test', { failureThreshold: 1 });
102+
103+
await cb
104+
.execute(async () => {
105+
throw new Error('fail');
106+
})
107+
.catch(() => {});
108+
109+
expect(cb.getState()).toBe(CircuitState.OPEN);
110+
cb.reset();
111+
expect(cb.getState()).toBe(CircuitState.CLOSED);
112+
});
113+
114+
it('resets failure count on success in CLOSED state', async () => {
115+
const cb = new CircuitBreaker('test', { failureThreshold: 3 });
116+
117+
// 2 failures
118+
await cb
119+
.execute(async () => {
120+
throw new Error('fail');
121+
})
122+
.catch(() => {});
123+
await cb
124+
.execute(async () => {
125+
throw new Error('fail');
126+
})
127+
.catch(() => {});
128+
129+
// 1 success resets counter
130+
await cb.execute(async () => 'ok');
131+
132+
// 2 more failures shouldn't trip the breaker
133+
await cb
134+
.execute(async () => {
135+
throw new Error('fail');
136+
})
137+
.catch(() => {});
138+
await cb
139+
.execute(async () => {
140+
throw new Error('fail');
141+
})
142+
.catch(() => {});
143+
144+
expect(cb.getState()).toBe(CircuitState.CLOSED);
145+
});
146+
});
147+
148+
describe('Bulkhead', () => {
149+
it('executes when under limit', async () => {
150+
const bh = new Bulkhead('test', 2);
151+
const result = await bh.execute(async () => 42);
152+
expect(result).toBe(42);
153+
});
154+
155+
it('queues when at limit', async () => {
156+
const bh = new Bulkhead('test', 1);
157+
const order: number[] = [];
158+
159+
const p1 = bh.execute(async () => {
160+
await new Promise((r) => setTimeout(r, 50));
161+
order.push(1);
162+
return 1;
163+
});
164+
165+
const p2 = bh.execute(async () => {
166+
order.push(2);
167+
return 2;
168+
});
169+
170+
await Promise.all([p1, p2]);
171+
expect(order).toEqual([1, 2]);
172+
});
173+
174+
it('reports stats correctly', async () => {
175+
const bh = new Bulkhead('test', 3);
176+
expect(bh.getStats()).toEqual({
177+
running: 0,
178+
queued: 0,
179+
maxConcurrent: 3,
180+
});
181+
});
182+
183+
it('decrements running on error', async () => {
184+
const bh = new Bulkhead('test', 2);
185+
await bh
186+
.execute(async () => {
187+
throw new Error('boom');
188+
})
189+
.catch(() => {});
190+
191+
expect(bh.getStats().running).toBe(0);
192+
});
193+
});
194+
195+
describe('withFallback', () => {
196+
it('returns primary result on success', async () => {
197+
const result = await withFallback(
198+
async () => 'primary',
199+
[async () => 'fallback']
200+
);
201+
expect(result).toBe('primary');
202+
});
203+
204+
it('uses fallback when primary fails', async () => {
205+
const result = await withFallback(async () => {
206+
throw new Error('fail');
207+
}, [async () => 'fallback']);
208+
expect(result).toBe('fallback');
209+
});
210+
211+
it('tries multiple fallbacks in order', async () => {
212+
const result = await withFallback(async () => {
213+
throw new Error('primary fail');
214+
}, [
215+
async () => {
216+
throw new Error('fallback1 fail');
217+
},
218+
async () => 'fallback2',
219+
]);
220+
expect(result).toBe('fallback2');
221+
});
222+
223+
it('throws when all attempts fail', async () => {
224+
await expect(
225+
withFallback(async () => {
226+
throw new Error('p');
227+
}, [
228+
async () => {
229+
throw new Error('f');
230+
},
231+
])
232+
).rejects.toThrow(/All attempts failed/);
233+
});
234+
});
235+
236+
describe('withTimeout', () => {
237+
it('returns result within timeout', async () => {
238+
const result = await withTimeout(async () => 'ok', 1000);
239+
expect(result).toBe('ok');
240+
});
241+
242+
it('rejects on timeout', async () => {
243+
await expect(
244+
withTimeout(
245+
() => new Promise((r) => setTimeout(() => r('late'), 200)),
246+
50
247+
)
248+
).rejects.toThrow(/timed out/);
249+
});
250+
251+
it('uses custom timeout message', async () => {
252+
await expect(
253+
withTimeout(
254+
() => new Promise((r) => setTimeout(() => r('late'), 200)),
255+
50,
256+
'Custom timeout msg'
257+
)
258+
).rejects.toThrow('Custom timeout msg');
259+
});
260+
});
261+
262+
describe('gracefulDegrade', () => {
263+
it('returns result on success', async () => {
264+
const result = await gracefulDegrade(async () => 42, -1);
265+
expect(result).toBe(42);
266+
});
267+
268+
it('returns default on failure', async () => {
269+
const result = await gracefulDegrade(async () => {
270+
throw new Error('fail');
271+
}, 'default');
272+
expect(result).toBe('default');
273+
});
274+
});
275+
});

0 commit comments

Comments
 (0)