Skip to content

Commit 20688fa

Browse files
authored
Merge pull request #470 from Emeka000/feat/418-wallet-orchestrator-retry-backoff
feat(wallets): add retry with backoff to wallet orchestrator (#418)
2 parents 7887625 + 91bde72 commit 20688fa

1 file changed

Lines changed: 293 additions & 5 deletions

File tree

src/wallets/wallet-retry.service.spec.ts

Lines changed: 293 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
import { WalletRetryService } from './wallet-retry.service';
22

3+
const makeConfig = (overrides: Record<string, number> = {}) => ({
4+
get: jest.fn((key: string, fallback: number) => overrides[key] ?? fallback),
5+
});
6+
37
describe('WalletRetryService', () => {
4-
const config = {
5-
get: jest.fn((_key: string, fallback: number) => fallback),
6-
};
78
let service: WalletRetryService;
89

910
beforeEach(() => {
1011
jest.clearAllMocks();
11-
service = new WalletRetryService(config as any);
12+
service = new WalletRetryService(makeConfig() as any);
1213
jest.spyOn(service as any, 'wait').mockResolvedValue(undefined);
1314
});
1415

16+
// -----------------------------------------------------------------------
17+
// Happy path
18+
// -----------------------------------------------------------------------
19+
20+
it('returns immediately when the operation succeeds on the first attempt', async () => {
21+
const operation = jest.fn().mockResolvedValue('ok');
22+
23+
await expect(
24+
service.execute({ operation: 'key_generation' }, operation),
25+
).resolves.toBe('ok');
26+
27+
expect(operation).toHaveBeenCalledTimes(1);
28+
expect((service as any).wait).not.toHaveBeenCalled();
29+
});
30+
1531
it('retries transient dependency failures with exponential backoff', async () => {
1632
const transient = Object.assign(new Error('connection reset'), {
1733
code: 'ECONNRESET',
@@ -31,7 +47,85 @@ describe('WalletRetryService', () => {
3147
expect((service as any).wait).toHaveBeenNthCalledWith(2, 200);
3248
});
3349

34-
it('does not retry invalid or non-transient failures', async () => {
50+
// -----------------------------------------------------------------------
51+
// maxAttempts override
52+
// -----------------------------------------------------------------------
53+
54+
it('respects maxAttempts=1 (no retries at all)', async () => {
55+
const transient = Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' });
56+
const operation = jest.fn().mockRejectedValue(transient);
57+
58+
await expect(
59+
service.execute({ operation: 'op', maxAttempts: 1 }, operation),
60+
).rejects.toBe(transient);
61+
62+
expect(operation).toHaveBeenCalledTimes(1);
63+
expect((service as any).wait).not.toHaveBeenCalled();
64+
});
65+
66+
it('clamps maxAttempts below 1 to 1 (treats 0 as a single attempt)', async () => {
67+
const transient = Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' });
68+
const operation = jest.fn().mockRejectedValue(transient);
69+
70+
await expect(
71+
service.execute({ operation: 'op', maxAttempts: 0 }, operation),
72+
).rejects.toBe(transient);
73+
74+
expect(operation).toHaveBeenCalledTimes(1);
75+
});
76+
77+
it('overrides default maxAttempts when provided', async () => {
78+
const transient = Object.assign(new Error('conn'), { code: 'ECONNRESET' });
79+
const operation = jest
80+
.fn()
81+
.mockRejectedValueOnce(transient)
82+
.mockResolvedValueOnce('done');
83+
84+
await expect(
85+
service.execute({ operation: 'op', maxAttempts: 2 }, operation),
86+
).resolves.toBe('done');
87+
88+
expect(operation).toHaveBeenCalledTimes(2);
89+
});
90+
91+
// -----------------------------------------------------------------------
92+
// Backoff cap
93+
// -----------------------------------------------------------------------
94+
95+
it('caps delay at maxDelayMs', async () => {
96+
const capped = new WalletRetryService(
97+
makeConfig({
98+
WALLET_API_RETRY_MAX_ATTEMPTS: 5,
99+
WALLET_API_RETRY_BASE_DELAY_MS: 1000,
100+
WALLET_API_RETRY_MAX_DELAY_MS: 2000,
101+
}) as any,
102+
);
103+
jest.spyOn(capped as any, 'wait').mockResolvedValue(undefined);
104+
105+
const transient = Object.assign(new Error('t'), { code: 'ECONNRESET' });
106+
const operation = jest
107+
.fn()
108+
.mockRejectedValueOnce(transient) // delay = min(1000*2^0, 2000) = 1000
109+
.mockRejectedValueOnce(transient) // delay = min(1000*2^1, 2000) = 2000
110+
.mockRejectedValueOnce(transient) // delay = min(1000*2^2, 2000) = 2000 (capped)
111+
.mockRejectedValueOnce(transient) // delay = min(1000*2^3, 2000) = 2000 (capped)
112+
.mockResolvedValueOnce('ok');
113+
114+
await expect(
115+
capped.execute({ operation: 'op' }, operation),
116+
).resolves.toBe('ok');
117+
118+
expect((capped as any).wait).toHaveBeenNthCalledWith(1, 1000);
119+
expect((capped as any).wait).toHaveBeenNthCalledWith(2, 2000);
120+
expect((capped as any).wait).toHaveBeenNthCalledWith(3, 2000);
121+
expect((capped as any).wait).toHaveBeenNthCalledWith(4, 2000);
122+
});
123+
124+
// -----------------------------------------------------------------------
125+
// isTransient: HTTP status codes
126+
// -----------------------------------------------------------------------
127+
128+
it('does not retry non-transient HTTP 4xx failures (400)', async () => {
35129
const invalidRequest = Object.assign(new Error('invalid key request'), {
36130
status: 400,
37131
});
@@ -44,4 +138,198 @@ describe('WalletRetryService', () => {
44138
expect(operation).toHaveBeenCalledTimes(1);
45139
expect((service as any).wait).not.toHaveBeenCalled();
46140
});
141+
142+
it('does not retry non-transient HTTP 4xx failures (401, 403, 404)', async () => {
143+
for (const status of [401, 403, 404]) {
144+
jest.clearAllMocks();
145+
service = new WalletRetryService(makeConfig() as any);
146+
jest.spyOn(service as any, 'wait').mockResolvedValue(undefined);
147+
148+
const err = Object.assign(new Error('client error'), { status });
149+
const operation = jest.fn().mockRejectedValue(err);
150+
151+
await expect(
152+
service.execute({ operation: 'op' }, operation),
153+
).rejects.toBe(err);
154+
155+
expect(operation).toHaveBeenCalledTimes(1);
156+
}
157+
});
158+
159+
it('retries HTTP 408 (Request Timeout)', async () => {
160+
const err = Object.assign(new Error('timeout'), { status: 408 });
161+
const operation = jest
162+
.fn()
163+
.mockRejectedValueOnce(err)
164+
.mockResolvedValueOnce('ok');
165+
166+
await expect(
167+
service.execute({ operation: 'op' }, operation),
168+
).resolves.toBe('ok');
169+
170+
expect(operation).toHaveBeenCalledTimes(2);
171+
});
172+
173+
it('retries HTTP 425 (Too Early)', async () => {
174+
const err = Object.assign(new Error('too early'), { status: 425 });
175+
const operation = jest
176+
.fn()
177+
.mockRejectedValueOnce(err)
178+
.mockResolvedValueOnce('ok');
179+
180+
await expect(
181+
service.execute({ operation: 'op' }, operation),
182+
).resolves.toBe('ok');
183+
184+
expect(operation).toHaveBeenCalledTimes(2);
185+
});
186+
187+
it('retries HTTP 429 (Rate Limited)', async () => {
188+
const err = Object.assign(new Error('rate limited'), { status: 429 });
189+
const operation = jest
190+
.fn()
191+
.mockRejectedValueOnce(err)
192+
.mockResolvedValueOnce('ok');
193+
194+
await expect(
195+
service.execute({ operation: 'op' }, operation),
196+
).resolves.toBe('ok');
197+
198+
expect(operation).toHaveBeenCalledTimes(2);
199+
});
200+
201+
it('retries HTTP 5xx server errors', async () => {
202+
for (const status of [500, 502, 503, 504]) {
203+
jest.clearAllMocks();
204+
service = new WalletRetryService(makeConfig() as any);
205+
jest.spyOn(service as any, 'wait').mockResolvedValue(undefined);
206+
207+
const err = Object.assign(new Error('server error'), { status });
208+
const operation = jest
209+
.fn()
210+
.mockRejectedValueOnce(err)
211+
.mockResolvedValueOnce('ok');
212+
213+
await expect(
214+
service.execute({ operation: 'op' }, operation),
215+
).resolves.toBe('ok');
216+
217+
expect(operation).toHaveBeenCalledTimes(2);
218+
}
219+
});
220+
221+
it('reads status from error.response.status when error.status is absent', async () => {
222+
const err = { response: { status: 503 }, message: 'service unavailable' };
223+
const operation = jest
224+
.fn()
225+
.mockRejectedValueOnce(err)
226+
.mockResolvedValueOnce('ok');
227+
228+
await expect(
229+
service.execute({ operation: 'op' }, operation),
230+
).resolves.toBe('ok');
231+
232+
expect(operation).toHaveBeenCalledTimes(2);
233+
});
234+
235+
// -----------------------------------------------------------------------
236+
// isTransient: network error codes
237+
// -----------------------------------------------------------------------
238+
239+
it.each([
240+
'ECONNABORTED',
241+
'ECONNREFUSED',
242+
'ECONNRESET',
243+
'EAI_AGAIN',
244+
'ENETUNREACH',
245+
'ETIMEDOUT',
246+
'UND_ERR_CONNECT_TIMEOUT',
247+
])('retries network error code %s', async (code) => {
248+
jest.clearAllMocks();
249+
service = new WalletRetryService(makeConfig() as any);
250+
jest.spyOn(service as any, 'wait').mockResolvedValue(undefined);
251+
252+
const err = Object.assign(new Error('network'), { code });
253+
const operation = jest
254+
.fn()
255+
.mockRejectedValueOnce(err)
256+
.mockResolvedValueOnce('ok');
257+
258+
await expect(
259+
service.execute({ operation: 'op' }, operation),
260+
).resolves.toBe('ok');
261+
262+
expect(operation).toHaveBeenCalledTimes(2);
263+
});
264+
265+
// -----------------------------------------------------------------------
266+
// Non-retriable: AbortError
267+
// -----------------------------------------------------------------------
268+
269+
it('does not retry AbortError even though it is a network-level failure', async () => {
270+
const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' });
271+
const operation = jest.fn().mockRejectedValue(abortErr);
272+
273+
await expect(
274+
service.execute({ operation: 'op' }, operation),
275+
).rejects.toBe(abortErr);
276+
277+
expect(operation).toHaveBeenCalledTimes(1);
278+
expect((service as any).wait).not.toHaveBeenCalled();
279+
});
280+
281+
// -----------------------------------------------------------------------
282+
// Exhaustion: all attempts fail with transient errors
283+
// -----------------------------------------------------------------------
284+
285+
it('throws the last transient error after all attempts are exhausted', async () => {
286+
const transient = Object.assign(new Error('always fails'), {
287+
code: 'ECONNRESET',
288+
});
289+
const operation = jest.fn().mockRejectedValue(transient);
290+
291+
await expect(
292+
service.execute({ operation: 'key_generation' }, operation),
293+
).rejects.toBe(transient);
294+
295+
expect(operation).toHaveBeenCalledTimes(3); // default maxAttempts=3
296+
expect((service as any).wait).toHaveBeenCalledTimes(2);
297+
});
298+
299+
// -----------------------------------------------------------------------
300+
// Config overrides are read from ConfigService
301+
// -----------------------------------------------------------------------
302+
303+
it('reads maxAttempts from config env WALLET_API_RETRY_MAX_ATTEMPTS', async () => {
304+
const configuredService = new WalletRetryService(
305+
makeConfig({ WALLET_API_RETRY_MAX_ATTEMPTS: 2 }) as any,
306+
);
307+
jest.spyOn(configuredService as any, 'wait').mockResolvedValue(undefined);
308+
309+
const transient = Object.assign(new Error('t'), { code: 'ETIMEDOUT' });
310+
const operation = jest.fn().mockRejectedValue(transient);
311+
312+
await expect(
313+
configuredService.execute({ operation: 'op' }, operation),
314+
).rejects.toBe(transient);
315+
316+
expect(operation).toHaveBeenCalledTimes(2);
317+
});
318+
319+
it('reads baseDelayMs from config env WALLET_API_RETRY_BASE_DELAY_MS', async () => {
320+
const configuredService = new WalletRetryService(
321+
makeConfig({ WALLET_API_RETRY_BASE_DELAY_MS: 50 }) as any,
322+
);
323+
jest.spyOn(configuredService as any, 'wait').mockResolvedValue(undefined);
324+
325+
const transient = Object.assign(new Error('t'), { code: 'ECONNRESET' });
326+
const operation = jest
327+
.fn()
328+
.mockRejectedValueOnce(transient)
329+
.mockResolvedValueOnce('ok');
330+
331+
await configuredService.execute({ operation: 'op' }, operation);
332+
333+
expect((configuredService as any).wait).toHaveBeenCalledWith(50);
334+
});
47335
});

0 commit comments

Comments
 (0)