11import { 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+
37describe ( '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