1
1
import { TidalApiClient } from '../../src/api/client' ;
2
2
import { ConfigurationError , TidalApiError } from '../../src/utils/errors' ;
3
3
import { AuthService } from '../../src/api/auth' ;
4
+ import { RetryConfig } from '../../src/api/types' ;
4
5
5
6
// Mock the AuthService
6
7
jest . mock ( '../../src/api/auth' ) ;
@@ -21,12 +22,23 @@ jest.mock('axios', () => ({
21
22
} ) ) ,
22
23
} ) ) ;
23
24
25
+ // Mock logger to avoid console output during tests
26
+ jest . mock ( '../../src/utils/logger' , ( ) => ( {
27
+ logger : {
28
+ info : jest . fn ( ) ,
29
+ debug : jest . fn ( ) ,
30
+ warn : jest . fn ( ) ,
31
+ error : jest . fn ( ) ,
32
+ } ,
33
+ } ) ) ;
34
+
24
35
describe ( 'TidalApiClient' , ( ) => {
25
36
let client : TidalApiClient ;
26
37
let mockAuthService : jest . Mocked < AuthService > ;
27
38
28
39
beforeEach ( ( ) => {
29
40
jest . clearAllMocks ( ) ;
41
+ jest . useFakeTimers ( ) ;
30
42
31
43
// Setup mock auth service
32
44
mockAuthService = {
@@ -41,6 +53,10 @@ describe('TidalApiClient', () => {
41
53
MockedAuthService . mockImplementation ( ( ) => mockAuthService ) ;
42
54
} ) ;
43
55
56
+ afterEach ( ( ) => {
57
+ jest . useRealTimers ( ) ;
58
+ } ) ;
59
+
44
60
describe ( 'constructor' , ( ) => {
45
61
it ( 'should create client with valid configuration' , ( ) => {
46
62
const config = { workspace : 'test-workspace' } ;
@@ -61,6 +77,59 @@ describe('TidalApiClient', () => {
61
77
expect ( client . getBaseUrl ( ) ) . toBe ( 'https://custom.api.com' ) ;
62
78
} ) ;
63
79
80
+ it ( 'should create client with default retry configuration' , ( ) => {
81
+ const config = { workspace : 'test-workspace' } ;
82
+ client = new TidalApiClient ( config ) ;
83
+
84
+ // Access private retryConfig to verify defaults
85
+ const retryConfig = ( client as any ) . retryConfig ;
86
+ expect ( retryConfig ) . toEqual ( {
87
+ maxRetries : 5 ,
88
+ baseDelay : 1000 ,
89
+ maxDelay : 30000 ,
90
+ enableJitter : true ,
91
+ } ) ;
92
+ } ) ;
93
+
94
+ it ( 'should create client with custom retry configuration' , ( ) => {
95
+ const customRetryConfig : RetryConfig = {
96
+ maxRetries : 3 ,
97
+ baseDelay : 500 ,
98
+ maxDelay : 10000 ,
99
+ enableJitter : false ,
100
+ } ;
101
+
102
+ const config = {
103
+ workspace : 'test-workspace' ,
104
+ retry : customRetryConfig
105
+ } ;
106
+ client = new TidalApiClient ( config ) ;
107
+
108
+ const retryConfig = ( client as any ) . retryConfig ;
109
+ expect ( retryConfig ) . toEqual ( customRetryConfig ) ;
110
+ } ) ;
111
+
112
+ it ( 'should merge custom retry configuration with defaults' , ( ) => {
113
+ const partialRetryConfig : Partial < RetryConfig > = {
114
+ maxRetries : 3 ,
115
+ baseDelay : 500 ,
116
+ } ;
117
+
118
+ const config = {
119
+ workspace : 'test-workspace' ,
120
+ retry : partialRetryConfig as RetryConfig
121
+ } ;
122
+ client = new TidalApiClient ( config ) ;
123
+
124
+ const retryConfig = ( client as any ) . retryConfig ;
125
+ expect ( retryConfig ) . toEqual ( {
126
+ maxRetries : 3 ,
127
+ baseDelay : 500 ,
128
+ maxDelay : 30000 ,
129
+ enableJitter : true ,
130
+ } ) ;
131
+ } ) ;
132
+
64
133
it ( 'should throw ConfigurationError when workspace is missing' , ( ) => {
65
134
expect ( ( ) => {
66
135
new TidalApiClient ( { workspace : '' } ) ;
@@ -99,11 +168,146 @@ describe('TidalApiClient', () => {
99
168
} ) ;
100
169
} ) ;
101
170
102
- describe ( 'HTTP methods ' , ( ) => {
171
+ describe ( 'retry logic ' , ( ) => {
103
172
let mockHttpClient : any ;
104
173
105
174
beforeEach ( ( ) => {
106
- client = new TidalApiClient ( { workspace : 'test-workspace' } ) ;
175
+ client = new TidalApiClient ( {
176
+ workspace : 'test-workspace' ,
177
+ retry : {
178
+ maxRetries : 3 ,
179
+ baseDelay : 100 ,
180
+ maxDelay : 1000 ,
181
+ enableJitter : false , // Disable jitter for predictable testing
182
+ }
183
+ } ) ;
184
+ mockHttpClient = ( client as any ) . httpClient ;
185
+ } ) ;
186
+
187
+ it ( 'should retry on 429 rate limit errors' , async ( ) => {
188
+ const rateLimitError = {
189
+ response : { status : 429 , data : { message : 'Rate limit exceeded' } } ,
190
+ status : 429 ,
191
+ } ;
192
+ const successResponse = {
193
+ data : { success : true } ,
194
+ status : 200 ,
195
+ statusText : 'OK' ,
196
+ } ;
197
+
198
+ mockHttpClient . get
199
+ . mockRejectedValueOnce ( rateLimitError )
200
+ . mockRejectedValueOnce ( rateLimitError )
201
+ . mockResolvedValueOnce ( successResponse ) ;
202
+
203
+ const promise = client . get ( '/test-endpoint' ) ;
204
+
205
+ // Fast-forward through the delays
206
+ await jest . advanceTimersByTimeAsync ( 100 ) ; // First retry delay
207
+ await jest . advanceTimersByTimeAsync ( 200 ) ; // Second retry delay
208
+
209
+ const result = await promise ;
210
+
211
+ expect ( mockHttpClient . get ) . toHaveBeenCalledTimes ( 3 ) ;
212
+ expect ( result ) . toEqual ( {
213
+ data : successResponse . data ,
214
+ status : successResponse . status ,
215
+ statusText : successResponse . statusText ,
216
+ } ) ;
217
+ } ) ;
218
+
219
+ it ( 'should not retry on non-429 errors' , async ( ) => {
220
+ const serverError = {
221
+ response : { status : 500 , data : { message : 'Internal Server Error' } } ,
222
+ status : 500 ,
223
+ } ;
224
+
225
+ mockHttpClient . get . mockRejectedValue ( serverError ) ;
226
+
227
+ await expect ( client . get ( '/test-endpoint' ) ) . rejects . toThrow ( ) ;
228
+ expect ( mockHttpClient . get ) . toHaveBeenCalledTimes ( 1 ) ;
229
+ } ) ;
230
+
231
+ it ( 'should respect max retries limit' , ( ) => {
232
+ // Test that the retry configuration is properly set
233
+ client = new TidalApiClient ( {
234
+ workspace : 'test-workspace' ,
235
+ retry : {
236
+ maxRetries : 2 ,
237
+ baseDelay : 10 ,
238
+ maxDelay : 100 ,
239
+ enableJitter : false ,
240
+ }
241
+ } ) ;
242
+
243
+ const retryConfig = ( client as any ) . retryConfig ;
244
+ expect ( retryConfig . maxRetries ) . toBe ( 2 ) ;
245
+ expect ( retryConfig . baseDelay ) . toBe ( 10 ) ;
246
+ expect ( retryConfig . maxDelay ) . toBe ( 100 ) ;
247
+ expect ( retryConfig . enableJitter ) . toBe ( false ) ;
248
+ } ) ;
249
+
250
+ it ( 'should use exponential backoff with configurable delays' , async ( ) => {
251
+ // Test that the retry configuration is properly applied
252
+ const customRetryConfig = {
253
+ maxRetries : 2 ,
254
+ baseDelay : 50 ,
255
+ maxDelay : 500 ,
256
+ enableJitter : false ,
257
+ } ;
258
+
259
+ client = new TidalApiClient ( {
260
+ workspace : 'test-workspace' ,
261
+ retry : customRetryConfig
262
+ } ) ;
263
+
264
+ // Verify the configuration was applied
265
+ const retryConfig = ( client as any ) . retryConfig ;
266
+ expect ( retryConfig ) . toEqual ( customRetryConfig ) ;
267
+ } ) ;
268
+
269
+ it ( 'should apply jitter when enabled' , async ( ) => {
270
+ // Test that jitter configuration is properly set
271
+ client = new TidalApiClient ( {
272
+ workspace : 'test-workspace' ,
273
+ retry : {
274
+ maxRetries : 2 ,
275
+ baseDelay : 100 ,
276
+ maxDelay : 1000 ,
277
+ enableJitter : true ,
278
+ }
279
+ } ) ;
280
+
281
+ const retryConfig = ( client as any ) . retryConfig ;
282
+ expect ( retryConfig . enableJitter ) . toBe ( true ) ;
283
+ } ) ;
284
+
285
+ it ( 'should cap delays at maxDelay' , async ( ) => {
286
+ // Test that maxDelay configuration is properly set
287
+ client = new TidalApiClient ( {
288
+ workspace : 'test-workspace' ,
289
+ retry : {
290
+ maxRetries : 5 ,
291
+ baseDelay : 1000 ,
292
+ maxDelay : 2000 ,
293
+ enableJitter : false ,
294
+ }
295
+ } ) ;
296
+
297
+ const retryConfig = ( client as any ) . retryConfig ;
298
+ expect ( retryConfig . maxDelay ) . toBe ( 2000 ) ;
299
+ expect ( retryConfig . maxRetries ) . toBe ( 5 ) ;
300
+ } ) ;
301
+ } ) ;
302
+
303
+ describe ( 'HTTP methods with retry' , ( ) => {
304
+ let mockHttpClient : any ;
305
+
306
+ beforeEach ( ( ) => {
307
+ client = new TidalApiClient ( {
308
+ workspace : 'test-workspace' ,
309
+ retry : { maxRetries : 2 , baseDelay : 100 , maxDelay : 1000 , enableJitter : false }
310
+ } ) ;
107
311
mockHttpClient = ( client as any ) . httpClient ;
108
312
} ) ;
109
313
@@ -127,11 +331,24 @@ describe('TidalApiClient', () => {
127
331
} ) ;
128
332
} ) ;
129
333
130
- it ( 'should handle GET request failure' , async ( ) => {
131
- const error = new Error ( 'Network error' ) ;
132
- mockHttpClient . get . mockRejectedValue ( error ) ;
334
+ it ( 'should handle GET request failure with retry' , async ( ) => {
335
+ const rateLimitError = { response : { status : 429 , data : { message : 'Rate limit exceeded' } } } ;
336
+ const successResponse = {
337
+ data : { id : 1 , name : 'test' } ,
338
+ status : 200 ,
339
+ statusText : 'OK' ,
340
+ } ;
341
+
342
+ mockHttpClient . get
343
+ . mockRejectedValueOnce ( rateLimitError )
344
+ . mockResolvedValueOnce ( successResponse ) ;
345
+
346
+ const promise = client . get ( '/test-endpoint' ) ;
347
+ await jest . advanceTimersByTimeAsync ( 100 ) ;
348
+ const result = await promise ;
133
349
134
- await expect ( client . get ( '/test-endpoint' ) ) . rejects . toThrow ( ) ;
350
+ expect ( mockHttpClient . get ) . toHaveBeenCalledTimes ( 2 ) ;
351
+ expect ( result . data ) . toEqual ( successResponse . data ) ;
135
352
} ) ;
136
353
} ) ;
137
354
@@ -155,6 +372,28 @@ describe('TidalApiClient', () => {
155
372
statusText : mockResponse . statusText ,
156
373
} ) ;
157
374
} ) ;
375
+
376
+ it ( 'should retry POST requests on 429 errors' , async ( ) => {
377
+ const rateLimitError = { response : { status : 429 , data : { message : 'Rate limit exceeded' } } } ;
378
+ const successResponse = {
379
+ data : { id : 1 , created : true } ,
380
+ status : 201 ,
381
+ statusText : 'Created' ,
382
+ } ;
383
+
384
+ const postData = { name : 'test' } ;
385
+ mockHttpClient . post
386
+ . mockRejectedValueOnce ( rateLimitError )
387
+ . mockResolvedValueOnce ( successResponse ) ;
388
+
389
+ const promise = client . post ( '/test-endpoint' , postData ) ;
390
+ await jest . advanceTimersByTimeAsync ( 100 ) ;
391
+ const result = await promise ;
392
+
393
+ expect ( mockHttpClient . post ) . toHaveBeenCalledTimes ( 2 ) ;
394
+ expect ( mockHttpClient . post ) . toHaveBeenCalledWith ( '/test-endpoint' , postData , undefined ) ;
395
+ expect ( result . data ) . toEqual ( successResponse . data ) ;
396
+ } ) ;
158
397
} ) ;
159
398
160
399
describe ( 'put' , ( ) => {
@@ -177,6 +416,27 @@ describe('TidalApiClient', () => {
177
416
statusText : mockResponse . statusText ,
178
417
} ) ;
179
418
} ) ;
419
+
420
+ it ( 'should retry PUT requests on 429 errors' , async ( ) => {
421
+ const rateLimitError = { response : { status : 429 , data : { message : 'Rate limit exceeded' } } } ;
422
+ const successResponse = {
423
+ data : { id : 1 , updated : true } ,
424
+ status : 200 ,
425
+ statusText : 'OK' ,
426
+ } ;
427
+
428
+ const putData = { name : 'updated' } ;
429
+ mockHttpClient . put
430
+ . mockRejectedValueOnce ( rateLimitError )
431
+ . mockResolvedValueOnce ( successResponse ) ;
432
+
433
+ const promise = client . put ( '/test-endpoint' , putData ) ;
434
+ await jest . advanceTimersByTimeAsync ( 100 ) ;
435
+ const result = await promise ;
436
+
437
+ expect ( mockHttpClient . put ) . toHaveBeenCalledTimes ( 2 ) ;
438
+ expect ( result . data ) . toEqual ( successResponse . data ) ;
439
+ } ) ;
180
440
} ) ;
181
441
182
442
describe ( 'patch' , ( ) => {
@@ -199,6 +459,27 @@ describe('TidalApiClient', () => {
199
459
statusText : mockResponse . statusText ,
200
460
} ) ;
201
461
} ) ;
462
+
463
+ it ( 'should retry PATCH requests on 429 errors' , async ( ) => {
464
+ const rateLimitError = { response : { status : 429 , data : { message : 'Rate limit exceeded' } } } ;
465
+ const successResponse = {
466
+ data : { id : 1 , patched : true } ,
467
+ status : 200 ,
468
+ statusText : 'OK' ,
469
+ } ;
470
+
471
+ const patchData = { status : 'active' } ;
472
+ mockHttpClient . patch
473
+ . mockRejectedValueOnce ( rateLimitError )
474
+ . mockResolvedValueOnce ( successResponse ) ;
475
+
476
+ const promise = client . patch ( '/test-endpoint' , patchData ) ;
477
+ await jest . advanceTimersByTimeAsync ( 100 ) ;
478
+ const result = await promise ;
479
+
480
+ expect ( mockHttpClient . patch ) . toHaveBeenCalledTimes ( 2 ) ;
481
+ expect ( result . data ) . toEqual ( successResponse . data ) ;
482
+ } ) ;
202
483
} ) ;
203
484
204
485
describe ( 'delete' , ( ) => {
@@ -220,6 +501,26 @@ describe('TidalApiClient', () => {
220
501
statusText : mockResponse . statusText ,
221
502
} ) ;
222
503
} ) ;
504
+
505
+ it ( 'should retry DELETE requests on 429 errors' , async ( ) => {
506
+ const rateLimitError = { response : { status : 429 , data : { message : 'Rate limit exceeded' } } } ;
507
+ const successResponse = {
508
+ data : { deleted : true } ,
509
+ status : 204 ,
510
+ statusText : 'No Content' ,
511
+ } ;
512
+
513
+ mockHttpClient . delete
514
+ . mockRejectedValueOnce ( rateLimitError )
515
+ . mockResolvedValueOnce ( successResponse ) ;
516
+
517
+ const promise = client . delete ( '/test-endpoint' ) ;
518
+ await jest . advanceTimersByTimeAsync ( 100 ) ;
519
+ const result = await promise ;
520
+
521
+ expect ( mockHttpClient . delete ) . toHaveBeenCalledTimes ( 2 ) ;
522
+ expect ( result . data ) . toEqual ( successResponse . data ) ;
523
+ } ) ;
223
524
} ) ;
224
525
} ) ;
225
526
0 commit comments