pdfdancer-client-typescript 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Tests for the retry mechanism in REST API calls.
3
+ */
4
+
5
+ import {PDFDancer} from '../pdfdancer_v1';
6
+ import {RetryConfig} from '../pdfdancer_v1';
7
+
8
+ // Mock the fetch function
9
+ global.fetch = jest.fn();
10
+
11
+ // Helper to create a mock response
12
+ function createMockResponse(status: number, body: unknown = {}, headers?: Record<string, string>): Response {
13
+ const bodyString = typeof body === 'string' ? body : JSON.stringify(body);
14
+ const responseHeaders = new Headers(headers);
15
+ return {
16
+ ok: status >= 200 && status < 300,
17
+ status,
18
+ statusText: status === 200 ? 'OK' : 'Error',
19
+ headers: responseHeaders,
20
+ text: async () => bodyString,
21
+ json: async () => typeof body === 'object' ? body : JSON.parse(body as string),
22
+ arrayBuffer: async () => new ArrayBuffer(0),
23
+ blob: async () => new Blob(),
24
+ formData: async () => new FormData(),
25
+ clone: function() {
26
+ return createMockResponse(status, body, headers);
27
+ },
28
+ body: null,
29
+ bodyUsed: false,
30
+ redirected: false,
31
+ type: 'basic',
32
+ url: ''
33
+ } as Response;
34
+ }
35
+
36
+ describe('Retry Mechanism', () => {
37
+ beforeEach(() => {
38
+ jest.clearAllMocks();
39
+ (global.fetch as jest.Mock).mockReset();
40
+ });
41
+
42
+ describe('RetryConfig', () => {
43
+ test('should use default retry config when none provided', async () => {
44
+ // Mock successful responses
45
+ (global.fetch as jest.Mock)
46
+ .mockResolvedValueOnce(createMockResponse(200, {token: 'test-token'}))
47
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
48
+
49
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // PDF header
50
+
51
+ // Should use default retry config
52
+ await expect(PDFDancer.open(pdfData, 'test-token')).resolves.toBeDefined();
53
+ });
54
+
55
+ test('should accept custom retry config', async () => {
56
+ const customRetryConfig: RetryConfig = {
57
+ maxRetries: 5,
58
+ initialDelay: 500,
59
+ maxDelay: 5000,
60
+ retryableStatusCodes: [429, 503],
61
+ retryOnNetworkError: true,
62
+ backoffMultiplier: 3,
63
+ useJitter: false
64
+ };
65
+
66
+ (global.fetch as jest.Mock)
67
+ .mockResolvedValueOnce(createMockResponse(200, {token: 'test-token'}))
68
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
69
+
70
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
71
+
72
+ await expect(
73
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, customRetryConfig)
74
+ ).resolves.toBeDefined();
75
+ });
76
+ });
77
+
78
+ describe('Retryable Status Codes', () => {
79
+ test('should retry on 429 (rate limit)', async () => {
80
+ const mockFetch = global.fetch as jest.Mock;
81
+
82
+ // First call returns 429, second call succeeds
83
+ // When token is provided, no token fetch call is made
84
+ mockFetch
85
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded'))
86
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
87
+
88
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
89
+ const retryConfig: RetryConfig = {
90
+ maxRetries: 2,
91
+ initialDelay: 10, // Use short delay for tests
92
+ useJitter: false
93
+ };
94
+
95
+ await expect(
96
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
97
+ ).resolves.toBeDefined();
98
+
99
+ // Should have made 2 fetch calls: 1 failed session + 1 retry
100
+ expect(mockFetch).toHaveBeenCalledTimes(2);
101
+ });
102
+
103
+ test('should retry on 500 (server error)', async () => {
104
+ const mockFetch = global.fetch as jest.Mock;
105
+
106
+ mockFetch
107
+ .mockResolvedValueOnce(createMockResponse(500, 'Internal server error'))
108
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
109
+
110
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
111
+ const retryConfig: RetryConfig = {
112
+ maxRetries: 2,
113
+ initialDelay: 10,
114
+ useJitter: false
115
+ };
116
+
117
+ await expect(
118
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
119
+ ).resolves.toBeDefined();
120
+
121
+ expect(mockFetch).toHaveBeenCalledTimes(2);
122
+ });
123
+
124
+ test('should retry on 502, 503, 504', async () => {
125
+ for (const statusCode of [502, 503, 504]) {
126
+ jest.clearAllMocks();
127
+ const mockFetch = global.fetch as jest.Mock;
128
+
129
+ mockFetch
130
+ .mockResolvedValueOnce(createMockResponse(statusCode, 'Service unavailable'))
131
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
132
+
133
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
134
+ const retryConfig: RetryConfig = {
135
+ maxRetries: 2,
136
+ initialDelay: 10,
137
+ useJitter: false
138
+ };
139
+
140
+ await expect(
141
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
142
+ ).resolves.toBeDefined();
143
+
144
+ expect(mockFetch).toHaveBeenCalledTimes(2);
145
+ }
146
+ });
147
+
148
+ test('should NOT retry on 400 (bad request)', async () => {
149
+ const mockFetch = global.fetch as jest.Mock;
150
+
151
+ mockFetch
152
+ .mockResolvedValueOnce(createMockResponse(400, 'Bad request'));
153
+
154
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
155
+ const retryConfig: RetryConfig = {
156
+ maxRetries: 3,
157
+ initialDelay: 10,
158
+ useJitter: false
159
+ };
160
+
161
+ await expect(
162
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
163
+ ).rejects.toThrow();
164
+
165
+ // Should only make 1 call (no retries for 400)
166
+ expect(mockFetch).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ test('should NOT retry on 404 (not found)', async () => {
170
+ const mockFetch = global.fetch as jest.Mock;
171
+
172
+ mockFetch
173
+ .mockResolvedValueOnce(createMockResponse(404, 'Not found'));
174
+
175
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
176
+ const retryConfig: RetryConfig = {
177
+ maxRetries: 3,
178
+ initialDelay: 10,
179
+ useJitter: false
180
+ };
181
+
182
+ await expect(
183
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
184
+ ).rejects.toThrow();
185
+
186
+ expect(mockFetch).toHaveBeenCalledTimes(1);
187
+ });
188
+ });
189
+
190
+ describe('Network Errors', () => {
191
+ test('should retry on network errors when retryOnNetworkError is true', async () => {
192
+ const mockFetch = global.fetch as jest.Mock;
193
+
194
+ mockFetch
195
+ .mockRejectedValueOnce(new Error('Network error'))
196
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
197
+
198
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
199
+ const retryConfig: RetryConfig = {
200
+ maxRetries: 2,
201
+ initialDelay: 10,
202
+ retryOnNetworkError: true,
203
+ useJitter: false
204
+ };
205
+
206
+ await expect(
207
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
208
+ ).resolves.toBeDefined();
209
+
210
+ expect(mockFetch).toHaveBeenCalledTimes(2);
211
+ });
212
+
213
+ test('should NOT retry on network errors when retryOnNetworkError is false', async () => {
214
+ const mockFetch = global.fetch as jest.Mock;
215
+
216
+ mockFetch
217
+ .mockRejectedValueOnce(new Error('Network error'));
218
+
219
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
220
+ const retryConfig: RetryConfig = {
221
+ maxRetries: 3,
222
+ initialDelay: 10,
223
+ retryOnNetworkError: false,
224
+ useJitter: false
225
+ };
226
+
227
+ await expect(
228
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
229
+ ).rejects.toThrow('Network error');
230
+
231
+ expect(mockFetch).toHaveBeenCalledTimes(1);
232
+ });
233
+ });
234
+
235
+ describe('Max Retries', () => {
236
+ test('should respect maxRetries limit', async () => {
237
+ const mockFetch = global.fetch as jest.Mock;
238
+
239
+ // All calls fail with 503
240
+ mockFetch
241
+ .mockResolvedValue(createMockResponse(503, 'Service unavailable'));
242
+
243
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
244
+ const retryConfig: RetryConfig = {
245
+ maxRetries: 3,
246
+ initialDelay: 10,
247
+ useJitter: false
248
+ };
249
+
250
+ await expect(
251
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
252
+ ).rejects.toThrow();
253
+
254
+ // Should make: 1 initial session call + 3 retries = 4 total
255
+ expect(mockFetch).toHaveBeenCalledTimes(4);
256
+ });
257
+
258
+ test('should not retry when maxRetries is 0', async () => {
259
+ const mockFetch = global.fetch as jest.Mock;
260
+
261
+ mockFetch
262
+ .mockResolvedValueOnce(createMockResponse(503, 'Service unavailable'));
263
+
264
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
265
+ const retryConfig: RetryConfig = {
266
+ maxRetries: 0,
267
+ initialDelay: 10,
268
+ useJitter: false
269
+ };
270
+
271
+ await expect(
272
+ PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig)
273
+ ).rejects.toThrow();
274
+
275
+ // Should only make 1 call (no retries)
276
+ expect(mockFetch).toHaveBeenCalledTimes(1);
277
+ });
278
+ });
279
+
280
+ describe('Exponential Backoff', () => {
281
+ test('should apply exponential backoff between retries', async () => {
282
+ const mockFetch = global.fetch as jest.Mock;
283
+ const delays: number[] = [];
284
+ const originalSetTimeout = global.setTimeout;
285
+
286
+ // Mock setTimeout to capture delays
287
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
288
+ if (delay) delays.push(delay);
289
+ return originalSetTimeout(callback, 0);
290
+ }) as unknown as typeof setTimeout;
291
+
292
+ mockFetch
293
+ .mockResolvedValueOnce(createMockResponse(503, 'Unavailable'))
294
+ .mockResolvedValueOnce(createMockResponse(503, 'Unavailable'))
295
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
296
+
297
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
298
+ const retryConfig: RetryConfig = {
299
+ maxRetries: 3,
300
+ initialDelay: 100,
301
+ backoffMultiplier: 2,
302
+ useJitter: false
303
+ };
304
+
305
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
306
+
307
+ // Restore original setTimeout
308
+ global.setTimeout = originalSetTimeout;
309
+
310
+ // Should have 2 delays (for 2 retries that eventually succeeded)
311
+ expect(delays.length).toBeGreaterThanOrEqual(2);
312
+
313
+ // First retry delay should be around initialDelay (100ms)
314
+ expect(delays[0]).toBeGreaterThanOrEqual(100);
315
+ expect(delays[0]).toBeLessThan(110);
316
+
317
+ // Second retry delay should be around initialDelay * backoffMultiplier (200ms)
318
+ expect(delays[1]).toBeGreaterThanOrEqual(200);
319
+ expect(delays[1]).toBeLessThan(210);
320
+ });
321
+
322
+ test('should cap delay at maxDelay', async () => {
323
+ const mockFetch = global.fetch as jest.Mock;
324
+ const delays: number[] = [];
325
+ const originalSetTimeout = global.setTimeout;
326
+
327
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
328
+ if (delay) delays.push(delay);
329
+ return originalSetTimeout(callback, 0);
330
+ }) as unknown as typeof setTimeout;
331
+
332
+ mockFetch
333
+ .mockResolvedValueOnce(createMockResponse(503, 'Unavailable'))
334
+ .mockResolvedValueOnce(createMockResponse(503, 'Unavailable'))
335
+ .mockResolvedValueOnce(createMockResponse(503, 'Unavailable'))
336
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
337
+
338
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
339
+ const retryConfig: RetryConfig = {
340
+ maxRetries: 4,
341
+ initialDelay: 1000,
342
+ maxDelay: 2000,
343
+ backoffMultiplier: 2,
344
+ useJitter: false
345
+ };
346
+
347
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
348
+
349
+ global.setTimeout = originalSetTimeout;
350
+
351
+ // All delays should be capped at maxDelay (2000ms)
352
+ delays.forEach(delay => {
353
+ expect(delay).toBeLessThanOrEqual(2000);
354
+ });
355
+ });
356
+ });
357
+
358
+ describe('Jitter', () => {
359
+ test('should apply jitter when useJitter is true', async () => {
360
+ const mockFetch = global.fetch as jest.Mock;
361
+ const delays: number[] = [];
362
+ const originalSetTimeout = global.setTimeout;
363
+
364
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
365
+ if (delay) delays.push(delay);
366
+ return originalSetTimeout(callback, 0);
367
+ }) as unknown as typeof setTimeout;
368
+
369
+ mockFetch
370
+ .mockResolvedValueOnce(createMockResponse(503, 'Unavailable'))
371
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
372
+
373
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
374
+ const retryConfig: RetryConfig = {
375
+ maxRetries: 2,
376
+ initialDelay: 1000,
377
+ backoffMultiplier: 2,
378
+ useJitter: true
379
+ };
380
+
381
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
382
+
383
+ global.setTimeout = originalSetTimeout;
384
+
385
+ // With jitter, delay should be between 50% and 100% of calculated delay
386
+ // For first retry: should be between 500 (50% of 1000) and 1000
387
+ expect(delays[0]).toBeGreaterThanOrEqual(500);
388
+ expect(delays[0]).toBeLessThanOrEqual(1000);
389
+ });
390
+
391
+ test('should not apply jitter when useJitter is false', async () => {
392
+ const mockFetch = global.fetch as jest.Mock;
393
+ const delays: number[] = [];
394
+ const originalSetTimeout = global.setTimeout;
395
+
396
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
397
+ if (delay) delays.push(delay);
398
+ return originalSetTimeout(callback, 0);
399
+ }) as unknown as typeof setTimeout;
400
+
401
+ mockFetch
402
+ .mockResolvedValueOnce(createMockResponse(503, 'Unavailable'))
403
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
404
+
405
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
406
+ const retryConfig: RetryConfig = {
407
+ maxRetries: 2,
408
+ initialDelay: 1000,
409
+ backoffMultiplier: 2,
410
+ useJitter: false
411
+ };
412
+
413
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
414
+
415
+ global.setTimeout = originalSetTimeout;
416
+
417
+ // Without jitter, delay should be exactly the calculated value
418
+ expect(delays[0]).toBe(1000);
419
+ });
420
+ });
421
+
422
+ describe('Retry-After Header', () => {
423
+ test('should respect Retry-After header with seconds format', async () => {
424
+ const mockFetch = global.fetch as jest.Mock;
425
+ const delays: number[] = [];
426
+ const originalSetTimeout = global.setTimeout;
427
+
428
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
429
+ if (delay) delays.push(delay);
430
+ return originalSetTimeout(callback, 0);
431
+ }) as unknown as typeof setTimeout;
432
+
433
+ // Return 429 with Retry-After: 5 seconds
434
+ mockFetch
435
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded', {'Retry-After': '5'}))
436
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
437
+
438
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
439
+ const retryConfig: RetryConfig = {
440
+ maxRetries: 2,
441
+ initialDelay: 1000,
442
+ respectRetryAfter: true,
443
+ useJitter: false
444
+ };
445
+
446
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
447
+
448
+ global.setTimeout = originalSetTimeout;
449
+
450
+ // Delay should be 5000ms (5 seconds from Retry-After header)
451
+ expect(delays[0]).toBe(5000);
452
+ });
453
+
454
+ test('should respect Retry-After header with HTTP-date format', async () => {
455
+ const mockFetch = global.fetch as jest.Mock;
456
+ const delays: number[] = [];
457
+ const originalSetTimeout = global.setTimeout;
458
+
459
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
460
+ if (delay) delays.push(delay);
461
+ return originalSetTimeout(callback, 0);
462
+ }) as unknown as typeof setTimeout;
463
+
464
+ // Set Retry-After to 3 seconds in the future
465
+ const retryDate = new Date(Date.now() + 3000);
466
+ mockFetch
467
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded', {'Retry-After': retryDate.toUTCString()}))
468
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
469
+
470
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
471
+ const retryConfig: RetryConfig = {
472
+ maxRetries: 2,
473
+ initialDelay: 1000,
474
+ respectRetryAfter: true,
475
+ useJitter: false
476
+ };
477
+
478
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
479
+
480
+ global.setTimeout = originalSetTimeout;
481
+
482
+ // Delay should be around 3000ms (may vary by more due to test execution time)
483
+ // We allow a wider range since there's latency between date creation and parsing
484
+ expect(delays[0]).toBeGreaterThanOrEqual(2000);
485
+ expect(delays[0]).toBeLessThanOrEqual(3100);
486
+ });
487
+
488
+ test('should ignore Retry-After when respectRetryAfter is false', async () => {
489
+ const mockFetch = global.fetch as jest.Mock;
490
+ const delays: number[] = [];
491
+ const originalSetTimeout = global.setTimeout;
492
+
493
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
494
+ if (delay) delays.push(delay);
495
+ return originalSetTimeout(callback, 0);
496
+ }) as unknown as typeof setTimeout;
497
+
498
+ mockFetch
499
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded', {'Retry-After': '60'}))
500
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
501
+
502
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
503
+ const retryConfig: RetryConfig = {
504
+ maxRetries: 2,
505
+ initialDelay: 1000,
506
+ respectRetryAfter: false,
507
+ useJitter: false
508
+ };
509
+
510
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
511
+
512
+ global.setTimeout = originalSetTimeout;
513
+
514
+ // Should use exponential backoff (1000ms) instead of Retry-After (60000ms)
515
+ expect(delays[0]).toBe(1000);
516
+ });
517
+
518
+ test('should cap Retry-After delay at maxDelay', async () => {
519
+ const mockFetch = global.fetch as jest.Mock;
520
+ const delays: number[] = [];
521
+ const originalSetTimeout = global.setTimeout;
522
+
523
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
524
+ if (delay) delays.push(delay);
525
+ return originalSetTimeout(callback, 0);
526
+ }) as unknown as typeof setTimeout;
527
+
528
+ // Retry-After specifies 60 seconds
529
+ mockFetch
530
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded', {'Retry-After': '60'}))
531
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
532
+
533
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
534
+ const retryConfig: RetryConfig = {
535
+ maxRetries: 2,
536
+ initialDelay: 1000,
537
+ maxDelay: 5000, // Cap at 5 seconds
538
+ respectRetryAfter: true,
539
+ useJitter: false
540
+ };
541
+
542
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
543
+
544
+ global.setTimeout = originalSetTimeout;
545
+
546
+ // Should be capped at maxDelay (5000ms) instead of 60000ms
547
+ expect(delays[0]).toBe(5000);
548
+ });
549
+
550
+ test('should fall back to exponential backoff when Retry-After is invalid', async () => {
551
+ const mockFetch = global.fetch as jest.Mock;
552
+ const delays: number[] = [];
553
+ const originalSetTimeout = global.setTimeout;
554
+
555
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
556
+ if (delay) delays.push(delay);
557
+ return originalSetTimeout(callback, 0);
558
+ }) as unknown as typeof setTimeout;
559
+
560
+ mockFetch
561
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded', {'Retry-After': 'invalid'}))
562
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
563
+
564
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
565
+ const retryConfig: RetryConfig = {
566
+ maxRetries: 2,
567
+ initialDelay: 1000,
568
+ respectRetryAfter: true,
569
+ useJitter: false
570
+ };
571
+
572
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
573
+
574
+ global.setTimeout = originalSetTimeout;
575
+
576
+ // Should fall back to exponential backoff (1000ms)
577
+ expect(delays[0]).toBe(1000);
578
+ });
579
+
580
+ test('should fall back to exponential backoff when Retry-After header is missing', async () => {
581
+ const mockFetch = global.fetch as jest.Mock;
582
+ const delays: number[] = [];
583
+ const originalSetTimeout = global.setTimeout;
584
+
585
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
586
+ if (delay) delays.push(delay);
587
+ return originalSetTimeout(callback, 0);
588
+ }) as unknown as typeof setTimeout;
589
+
590
+ mockFetch
591
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded'))
592
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
593
+
594
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
595
+ const retryConfig: RetryConfig = {
596
+ maxRetries: 2,
597
+ initialDelay: 1000,
598
+ respectRetryAfter: true,
599
+ useJitter: false
600
+ };
601
+
602
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
603
+
604
+ global.setTimeout = originalSetTimeout;
605
+
606
+ // Should fall back to exponential backoff (1000ms)
607
+ expect(delays[0]).toBe(1000);
608
+ });
609
+
610
+ test('should use 0ms delay when Retry-After date is in the past', async () => {
611
+ const mockFetch = global.fetch as jest.Mock;
612
+ const delays: number[] = [];
613
+ const originalSetTimeout = global.setTimeout;
614
+
615
+ global.setTimeout = jest.fn((callback: () => void, delay?: number) => {
616
+ if (delay !== undefined) delays.push(delay);
617
+ return originalSetTimeout(callback, 0);
618
+ }) as unknown as typeof setTimeout;
619
+
620
+ // Set Retry-After to 1 second in the past
621
+ const retryDate = new Date(Date.now() - 1000);
622
+ mockFetch
623
+ .mockResolvedValueOnce(createMockResponse(429, 'Rate limit exceeded', {'Retry-After': retryDate.toUTCString()}))
624
+ .mockResolvedValueOnce(createMockResponse(200, 'session-123'));
625
+
626
+ const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
627
+ const retryConfig: RetryConfig = {
628
+ maxRetries: 2,
629
+ initialDelay: 1000,
630
+ respectRetryAfter: true,
631
+ useJitter: false
632
+ };
633
+
634
+ await PDFDancer.open(pdfData, 'test-token', undefined, undefined, retryConfig);
635
+
636
+ global.setTimeout = originalSetTimeout;
637
+
638
+ // Should use 0ms delay (immediate retry)
639
+ expect(delays[0]).toBe(0);
640
+ });
641
+ });
642
+ });
@@ -32,7 +32,7 @@ function getInstallSalt(): string {
32
32
 
33
33
  // Create directory if it doesn't exist
34
34
  if (!fs.existsSync(saltDir)) {
35
- fs.mkdirSync(saltDir, { recursive: true, mode: 0o700 });
35
+ fs.mkdirSync(saltDir, {recursive: true, mode: 0o700});
36
36
  }
37
37
 
38
38
  // Read existing salt or generate new one
@@ -40,7 +40,7 @@ function getInstallSalt(): string {
40
40
  return fs.readFileSync(saltFile, 'utf8').trim();
41
41
  } else {
42
42
  const salt = crypto.randomBytes(16).toString('hex');
43
- fs.writeFileSync(saltFile, salt, { mode: 0o600 });
43
+ fs.writeFileSync(saltFile, salt, {mode: 0o600});
44
44
  return salt;
45
45
  }
46
46
  } catch (error) {
@@ -54,13 +54,26 @@ function getInstallSalt(): string {
54
54
  * Note: This is limited on the client side and may not always be accurate
55
55
  */
56
56
  async function getClientIP(): Promise<string> {
57
- try {
58
- // In browser, we can't reliably get the real IP
59
- // Return a placeholder that will be consistent per session
60
- return 'client-unknown';
61
- } catch {
57
+ // In the browser, just return a placeholder
58
+ if (typeof window !== 'undefined') {
62
59
  return 'client-unknown';
63
60
  }
61
+
62
+ // --- Running on server side ---
63
+ // Try to find a non-internal IPv4 address from network interfaces
64
+ const interfaces = os.networkInterfaces();
65
+ for (const name of Object.keys(interfaces)) {
66
+ const netList = interfaces[name];
67
+ if (!netList) continue;
68
+ for (const net of netList) {
69
+ if (net.family === 'IPv4' && !net.internal) {
70
+ return net.address;
71
+ }
72
+ }
73
+ }
74
+
75
+ // Fallback if nothing found
76
+ return 'server-unknown';
64
77
  }
65
78
 
66
79
  /**