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.
- package/.github/workflows/ci.yml +37 -0
- package/.github/workflows/publish.yml +34 -0
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/__generated/revenuecat-api-v2.d.ts +5110 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/rateLimitMiddleware.d.ts +3 -0
- package/dist/rateLimitMiddleware.d.ts.map +1 -0
- package/dist/rateLimitMiddleware.js +160 -0
- package/eslint.config.mjs +11 -0
- package/package.json +43 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/generate_openapi_client.sh +9 -0
- package/test/rateLimitMiddleware.test.ts +609 -0
- package/tsconfig.json +114 -0
|
@@ -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
|
+
});
|