revenuecat-api 1.0.2

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,609 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createRateLimitMiddleware } from "../src/rateLimitMiddleware";
3
+
4
+ // Mock fetch globally
5
+ const mockFetch = vi.fn();
6
+ global.fetch = mockFetch;
7
+
8
+ // Mock console.warn to capture warnings
9
+ const mockConsoleWarn = vi.fn();
10
+ global.console.warn = mockConsoleWarn;
11
+
12
+ // Mock callback parameters that match MiddlewareCallbackParams
13
+ const createMockCallbackParams = (request: Request) => ({
14
+ request,
15
+ options: {
16
+ baseUrl: "https://api.revenuecat.com",
17
+ parseAs: "json" as const,
18
+ querySerializer: vi.fn(),
19
+ bodySerializer: vi.fn(),
20
+ fetch: mockFetch,
21
+ },
22
+ schemaPath: `/v1/${request.url.substring(30)}`,
23
+ params: {},
24
+ id: "test-id",
25
+ });
26
+
27
+ describe("Rate Limit Middleware", () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ vi.useFakeTimers();
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ describe("onRequest", () => {
38
+ it("should not delay requests when endpoint is not throttled", async () => {
39
+ const middleware = createRateLimitMiddleware();
40
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
41
+
42
+ const result = await middleware.onRequest?.(
43
+ createMockCallbackParams(request)
44
+ );
45
+
46
+ expect(result).toBeUndefined();
47
+ expect(mockFetch).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it("should delay requests when endpoint is throttled", async () => {
51
+ const middleware = createRateLimitMiddleware();
52
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
53
+
54
+ // Mock all retries to return 429 so the endpoint stays throttled
55
+ mockFetch.mockResolvedValue(
56
+ new Response("Still rate limited", {
57
+ status: 429,
58
+ headers: { "Retry-After": "2" },
59
+ })
60
+ );
61
+
62
+ // First, trigger a 429 response to set throttling state
63
+ const onResponsePromise = middleware.onResponse?.({
64
+ ...createMockCallbackParams(request),
65
+ response: new Response("Rate limit exceeded", {
66
+ status: 429,
67
+ headers: { "Retry-After": "2" },
68
+ }),
69
+ });
70
+
71
+ // Fast-forward through all retry attempts (3 retries)
72
+ for (let i = 0; i < 3; i++) {
73
+ vi.advanceTimersByTime(2000);
74
+ await vi.runAllTimersAsync();
75
+ }
76
+
77
+ await onResponsePromise;
78
+
79
+ // Now try a second request - it should be delayed
80
+ const secondRequest = new Request(
81
+ "https://api.revenuecat.com/v1/subscribers"
82
+ );
83
+ const onRequestPromise = middleware.onRequest?.(
84
+ createMockCallbackParams(secondRequest)
85
+ );
86
+
87
+ // Fast-forward time by 1 second (less than the 2 second retry-after)
88
+ vi.advanceTimersByTime(1000);
89
+ await vi.runAllTimersAsync();
90
+
91
+ // The request should still be pending
92
+ expect(onRequestPromise).toBeInstanceOf(Promise);
93
+
94
+ // Fast-forward to complete the delay
95
+ vi.advanceTimersByTime(1000);
96
+ await vi.runAllTimersAsync();
97
+
98
+ const result = await onRequestPromise;
99
+ expect(result).toBeUndefined();
100
+ });
101
+
102
+ it("should not warn when queue is empty", async () => {
103
+ const middleware = createRateLimitMiddleware();
104
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
105
+
106
+ // Call onRequest - should not warn since queue is empty
107
+ await middleware.onRequest?.(createMockCallbackParams(request));
108
+
109
+ expect(mockConsoleWarn).not.toHaveBeenCalled();
110
+ });
111
+ });
112
+
113
+ describe("onResponse", () => {
114
+ it("should return response unchanged for non-429 responses", async () => {
115
+ const middleware = createRateLimitMiddleware();
116
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
117
+ const response = new Response("Success", { status: 200 });
118
+
119
+ const result = await middleware.onResponse?.({
120
+ ...createMockCallbackParams(request),
121
+ response,
122
+ });
123
+
124
+ expect(result).toBeUndefined();
125
+ });
126
+
127
+ it("should retry 429 responses after waiting for Retry-After time and return success if retry succeeds", async () => {
128
+ const middleware = createRateLimitMiddleware();
129
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
130
+
131
+ // Mock the retry response to be successful
132
+ mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 }));
133
+
134
+ const response429 = new Response("Rate limit exceeded", {
135
+ status: 429,
136
+ headers: { "Retry-After": "1" },
137
+ });
138
+
139
+ const onResponsePromise = middleware.onResponse?.({
140
+ ...createMockCallbackParams(request),
141
+ response: response429,
142
+ });
143
+
144
+ // Fast-forward time to complete the retry-after delay
145
+ vi.advanceTimersByTime(1000);
146
+ await vi.runAllTimersAsync();
147
+
148
+ const result = await onResponsePromise;
149
+
150
+ expect(mockFetch).toHaveBeenCalledWith(request, {});
151
+ expect(result).toBeInstanceOf(Response);
152
+ expect(result?.status).toBe(200);
153
+ });
154
+
155
+ it("should return original 429 response if all retries fail with 429", async () => {
156
+ const middleware = createRateLimitMiddleware();
157
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
158
+
159
+ // Mock all retries to return 429
160
+ mockFetch.mockResolvedValue(
161
+ new Response("Still rate limited", {
162
+ status: 429,
163
+ headers: { "Retry-After": "1" },
164
+ })
165
+ );
166
+
167
+ const originalResponse = new Response("Rate limit exceeded", {
168
+ status: 429,
169
+ headers: { "Retry-After": "1" },
170
+ });
171
+
172
+ const onResponsePromise = middleware.onResponse?.({
173
+ ...createMockCallbackParams(request),
174
+ response: originalResponse,
175
+ });
176
+
177
+ // Fast-forward through all retry attempts
178
+ for (let i = 0; i < 3; i++) {
179
+ vi.advanceTimersByTime(1000);
180
+ await vi.runAllTimersAsync();
181
+ }
182
+
183
+ const result = await onResponsePromise;
184
+
185
+ // Should have called fetch 3 times (the retry attempts)
186
+ expect(mockFetch).toHaveBeenCalledTimes(3);
187
+ // Should return the last 429 response after max retries
188
+ expect(result).toBeInstanceOf(Response);
189
+ expect(result?.status).toBe(429);
190
+ });
191
+
192
+ it("should handle missing Retry-After header with fallback and succeed if retry does", async () => {
193
+ const middleware = createRateLimitMiddleware();
194
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
195
+
196
+ // Mock successful retry
197
+ mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 }));
198
+
199
+ const response429 = new Response("Rate limit exceeded", {
200
+ status: 429,
201
+ // No Retry-After header
202
+ });
203
+
204
+ const onResponsePromise = middleware.onResponse?.({
205
+ ...createMockCallbackParams(request),
206
+ response: response429,
207
+ });
208
+
209
+ // Fast-forward time by 1 second (fallback delay)
210
+ vi.advanceTimersByTime(1000);
211
+ await vi.runAllTimersAsync();
212
+
213
+ const result = await onResponsePromise;
214
+
215
+ expect(mockFetch).toHaveBeenCalledWith(request, {});
216
+ expect(result).toBeInstanceOf(Response);
217
+ expect(result?.status).toBe(200);
218
+ });
219
+
220
+ it("should handle invalid Retry-After header with fallback and succeed if retry does", async () => {
221
+ const middleware = createRateLimitMiddleware();
222
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
223
+
224
+ // Mock successful retry
225
+ mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 }));
226
+
227
+ const response429 = new Response("Rate limit exceeded", {
228
+ status: 429,
229
+ headers: { "Retry-After": "invalid" },
230
+ });
231
+
232
+ const onResponsePromise = middleware.onResponse?.({
233
+ ...createMockCallbackParams(request),
234
+ response: response429,
235
+ });
236
+
237
+ // Fast-forward time by 1 second (fallback delay)
238
+ vi.advanceTimersByTime(1000);
239
+ await vi.runAllTimersAsync();
240
+
241
+ const result = await onResponsePromise;
242
+
243
+ expect(mockFetch).toHaveBeenCalledWith(request, {});
244
+ expect(result).toBeInstanceOf(Response);
245
+ expect(result?.status).toBe(200);
246
+ });
247
+
248
+ it("should handle fetch errors during retry and throw", async () => {
249
+ const middleware = createRateLimitMiddleware();
250
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
251
+
252
+ // Mock fetch to throw an error
253
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
254
+
255
+ const response429 = new Response("Rate limit exceeded", {
256
+ status: 429,
257
+ headers: { "Retry-After": "1" },
258
+ });
259
+
260
+ let caughtError: Error | undefined;
261
+ const onResponsePromise = (
262
+ middleware.onResponse?.({
263
+ ...createMockCallbackParams(request),
264
+ response: response429,
265
+ }) as Promise<Response | undefined>
266
+ ).catch((error) => {
267
+ // Work around Vitest issue catching unhandled rejections
268
+ caughtError = error;
269
+ });
270
+
271
+ // Fast-forward time to complete the retry-after delay
272
+ vi.advanceTimersByTime(1000);
273
+ await vi.runAllTimersAsync();
274
+ await onResponsePromise;
275
+
276
+ expect(caughtError).toBeInstanceOf(Error);
277
+ expect(caughtError?.message).toBe("Network error");
278
+ });
279
+
280
+ it("should not retry when response body has retryable: false", async () => {
281
+ const middleware = createRateLimitMiddleware();
282
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
283
+
284
+ const response429 = new Response(
285
+ JSON.stringify({
286
+ type: "rate_limit_error",
287
+ message: "Rate limit exceeded",
288
+ retryable: false,
289
+ doc_url: "https://errors.rev.cat/rate-limit-error",
290
+ backoff_ms: 1000,
291
+ }),
292
+ {
293
+ status: 429,
294
+ headers: { "Retry-After": "1", "Content-Type": "application/json" },
295
+ }
296
+ );
297
+
298
+ const result = await middleware.onResponse?.({
299
+ ...createMockCallbackParams(request),
300
+ response: response429,
301
+ });
302
+
303
+ // Should return the original response without retrying
304
+ expect(result).toBe(response429);
305
+ expect(mockFetch).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it("should retry when response body has retryable: true", async () => {
309
+ const middleware = createRateLimitMiddleware();
310
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
311
+
312
+ // Mock successful retry
313
+ mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 }));
314
+
315
+ const response429 = new Response(
316
+ JSON.stringify({
317
+ type: "rate_limit_error",
318
+ message: "Rate limit exceeded",
319
+ retryable: true,
320
+ doc_url: "https://errors.rev.cat/rate-limit-error",
321
+ backoff_ms: 1000,
322
+ }),
323
+ {
324
+ status: 429,
325
+ headers: { "Retry-After": "1", "Content-Type": "application/json" },
326
+ }
327
+ );
328
+
329
+ const onResponsePromise = middleware.onResponse?.({
330
+ ...createMockCallbackParams(request),
331
+ response: response429,
332
+ });
333
+
334
+ // Fast-forward time to complete the retry-after delay
335
+ vi.advanceTimersByTime(1000);
336
+ await vi.runAllTimersAsync();
337
+
338
+ const result = await onResponsePromise;
339
+
340
+ expect(mockFetch).toHaveBeenCalledWith(request, {});
341
+ expect(result).toBeInstanceOf(Response);
342
+ expect(result?.status).toBe(200);
343
+ });
344
+
345
+ it("should retry when response body has no retryable field", async () => {
346
+ const middleware = createRateLimitMiddleware();
347
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
348
+
349
+ // Mock successful retry
350
+ mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 }));
351
+
352
+ const response429 = new Response(
353
+ JSON.stringify({
354
+ type: "rate_limit_error",
355
+ message: "Rate limit exceeded",
356
+ doc_url: "https://errors.rev.cat/rate-limit-error",
357
+ backoff_ms: 1000,
358
+ }),
359
+ {
360
+ status: 429,
361
+ headers: { "Retry-After": "1", "Content-Type": "application/json" },
362
+ }
363
+ );
364
+
365
+ const onResponsePromise = middleware.onResponse?.({
366
+ ...createMockCallbackParams(request),
367
+ response: response429,
368
+ });
369
+
370
+ // Fast-forward time to complete the retry-after delay
371
+ vi.advanceTimersByTime(1000);
372
+ await vi.runAllTimersAsync();
373
+
374
+ const result = await onResponsePromise;
375
+
376
+ expect(mockFetch).toHaveBeenCalledWith(request, {});
377
+ expect(result).toBeInstanceOf(Response);
378
+ expect(result?.status).toBe(200);
379
+ });
380
+
381
+ it("should retry when response body is not valid JSON", async () => {
382
+ const middleware = createRateLimitMiddleware();
383
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
384
+
385
+ // Mock successful retry
386
+ mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 }));
387
+
388
+ const response429 = new Response("Invalid JSON response", {
389
+ status: 429,
390
+ headers: { "Retry-After": "1" },
391
+ });
392
+
393
+ const onResponsePromise = middleware.onResponse?.({
394
+ ...createMockCallbackParams(request),
395
+ response: response429,
396
+ });
397
+
398
+ // Fast-forward time to complete the retry-after delay
399
+ vi.advanceTimersByTime(1000);
400
+ await vi.runAllTimersAsync();
401
+
402
+ const result = await onResponsePromise;
403
+
404
+ expect(mockFetch).toHaveBeenCalledWith(request, {});
405
+ expect(result).toBeInstanceOf(Response);
406
+ expect(result?.status).toBe(200);
407
+ });
408
+
409
+ it("should stop retrying when retry response has retryable: false", async () => {
410
+ const middleware = createRateLimitMiddleware();
411
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
412
+
413
+ // Mock retry response to return 429 with retryable: false
414
+ mockFetch.mockResolvedValueOnce(
415
+ new Response(
416
+ JSON.stringify({
417
+ type: "rate_limit_error",
418
+ message: "Rate limit exceeded",
419
+ retryable: false,
420
+ doc_url: "https://errors.rev.cat/rate-limit-error",
421
+ backoff_ms: 1000,
422
+ }),
423
+ {
424
+ status: 429,
425
+ headers: { "Retry-After": "1", "Content-Type": "application/json" },
426
+ }
427
+ )
428
+ );
429
+
430
+ const response429 = new Response(
431
+ JSON.stringify({
432
+ type: "rate_limit_error",
433
+ message: "Rate limit exceeded",
434
+ retryable: true,
435
+ doc_url: "https://errors.rev.cat/rate-limit-error",
436
+ backoff_ms: 1000,
437
+ }),
438
+ {
439
+ status: 429,
440
+ headers: { "Retry-After": "1", "Content-Type": "application/json" },
441
+ }
442
+ );
443
+
444
+ const onResponsePromise = middleware.onResponse?.({
445
+ ...createMockCallbackParams(request),
446
+ response: response429,
447
+ });
448
+
449
+ // Fast-forward time to complete the retry-after delay
450
+ vi.advanceTimersByTime(1000);
451
+ await vi.runAllTimersAsync();
452
+
453
+ const result = await onResponsePromise;
454
+
455
+ // Should have called fetch once for the retry
456
+ expect(mockFetch).toHaveBeenCalledTimes(1);
457
+ expect(mockFetch).toHaveBeenCalledWith(request, {});
458
+ // Should return the retry response (429 with retryable: false)
459
+ expect(result).toBeInstanceOf(Response);
460
+ expect(result?.status).toBe(429);
461
+ });
462
+ });
463
+
464
+ describe("endpoint-specific throttling", () => {
465
+ it("should throttle different endpoints independently", async () => {
466
+ const middleware = createRateLimitMiddleware();
467
+ const request1 = new Request("https://api.revenuecat.com/v1/subscribers");
468
+ const request2 = new Request("https://api.revenuecat.com/v1/products");
469
+
470
+ // Mock all retries to return 429
471
+ mockFetch.mockResolvedValue(
472
+ new Response("Still rate limited", {
473
+ status: 429,
474
+ headers: { "Retry-After": "2" },
475
+ })
476
+ );
477
+
478
+ // Throttle the first endpoint
479
+ const onResponsePromise = middleware.onResponse?.({
480
+ ...createMockCallbackParams(request1),
481
+ response: new Response("Rate limit exceeded", {
482
+ status: 429,
483
+ headers: { "Retry-After": "2" },
484
+ }),
485
+ });
486
+
487
+ // Fast-forward through all retry attempts (3 retries)
488
+ for (let i = 0; i < 3; i++) {
489
+ vi.advanceTimersByTime(2000);
490
+ await vi.runAllTimersAsync();
491
+ }
492
+
493
+ await onResponsePromise;
494
+
495
+ // The second endpoint should not be affected
496
+ const result = await middleware.onRequest?.(
497
+ createMockCallbackParams(request2)
498
+ );
499
+ expect(result).toBeUndefined();
500
+ });
501
+
502
+ it("should use method and pathname for endpoint key", async () => {
503
+ const middleware = createRateLimitMiddleware();
504
+
505
+ // These should be treated as different endpoints
506
+ const getRequest = new Request(
507
+ "https://api.revenuecat.com/v1/subscribers"
508
+ );
509
+ const postRequest = new Request(
510
+ "https://api.revenuecat.com/v1/subscribers",
511
+ { method: "POST" }
512
+ );
513
+
514
+ // Mock all retries to return 429
515
+ mockFetch.mockResolvedValue(
516
+ new Response("Still rate limited", {
517
+ status: 429,
518
+ headers: { "Retry-After": "2" },
519
+ })
520
+ );
521
+
522
+ // Throttle GET endpoint
523
+ const onResponsePromise = middleware.onResponse?.({
524
+ ...createMockCallbackParams(getRequest),
525
+ response: new Response("Rate limit exceeded", {
526
+ status: 429,
527
+ headers: { "Retry-After": "2" },
528
+ }),
529
+ });
530
+
531
+ // Fast-forward through all retry attempts (3 retries)
532
+ for (let i = 0; i < 3; i++) {
533
+ vi.advanceTimersByTime(2000);
534
+ await vi.runAllTimersAsync();
535
+ }
536
+
537
+ await onResponsePromise;
538
+
539
+ // POST endpoint should not be affected
540
+ const result = await middleware.onRequest?.(
541
+ createMockCallbackParams(postRequest)
542
+ );
543
+ expect(result).toBeUndefined();
544
+ });
545
+ });
546
+
547
+ describe("multiple retries", () => {
548
+ it("should return 429 response without retrying", async () => {
549
+ const middleware = createRateLimitMiddleware();
550
+ const request = new Request("https://api.revenuecat.com/v1/subscribers");
551
+
552
+ // Mock all retries to return 429
553
+ mockFetch.mockResolvedValue(
554
+ new Response("Still rate limited", {
555
+ status: 429,
556
+ headers: { "Retry-After": "1" },
557
+ })
558
+ );
559
+
560
+ const originalResponse = new Response("Rate limit exceeded", {
561
+ status: 429,
562
+ headers: { "Retry-After": "1" },
563
+ });
564
+
565
+ const onResponsePromise = middleware.onResponse?.({
566
+ ...createMockCallbackParams(request),
567
+ response: originalResponse,
568
+ });
569
+
570
+ // Fast-forward through all retry attempts
571
+ for (let i = 0; i < 3; i++) {
572
+ vi.advanceTimersByTime(1000);
573
+ await vi.runAllTimersAsync();
574
+ }
575
+
576
+ const result = await onResponsePromise;
577
+
578
+ // Should have called fetch 3 times (the retry attempts)
579
+ expect(mockFetch).toHaveBeenCalledTimes(3);
580
+ // Should return the last 429 response after max retries
581
+ expect(result).toBeInstanceOf(Response);
582
+ expect(result?.status).toBe(429);
583
+ });
584
+ });
585
+
586
+ describe("concurrent requests", () => {
587
+ it("should handle multiple concurrent requests to the same endpoint", async () => {
588
+ const middleware = createRateLimitMiddleware();
589
+ const baseUrl = "https://api.revenuecat.com/v1/subscribers";
590
+
591
+ // Create multiple requests
592
+ const requests = Array.from(
593
+ { length: 5 },
594
+ (_, i) => new Request(`${baseUrl}?id=${i}`)
595
+ );
596
+
597
+ // Start all requests - they should all complete immediately since no throttling
598
+ const onRequestPromises = requests.map((request) =>
599
+ middleware.onRequest?.(createMockCallbackParams(request))
600
+ );
601
+
602
+ // All requests should complete immediately
603
+ const results = await Promise.all(onRequestPromises);
604
+ results.forEach((result) => {
605
+ expect(result).toBeUndefined();
606
+ });
607
+ });
608
+ });
609
+ });