UNPKG

revenuecat-api

Version:

Type-safe RevenueCat API client using fetch with automatic rate limiting

610 lines (495 loc) 19.9 kB
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRateLimitMiddleware } from "../src/rateLimitMiddleware"; // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; // Mock console.warn to capture warnings const mockConsoleWarn = vi.fn(); global.console.warn = mockConsoleWarn; // Mock callback parameters that match MiddlewareCallbackParams const createMockCallbackParams = (request: Request) => ({ request, options: { baseUrl: "https://api.revenuecat.com", parseAs: "json" as const, querySerializer: vi.fn(), bodySerializer: vi.fn(), fetch: mockFetch, }, schemaPath: `/v1/${request.url.substring(30)}`, params: {}, id: "test-id", }); describe("Rate Limit Middleware", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe("onRequest", () => { it("should not delay requests when endpoint is not throttled", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); const result = await middleware.onRequest?.( createMockCallbackParams(request) ); expect(result).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); }); it("should delay requests when endpoint is throttled", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock all retries to return 429 so the endpoint stays throttled mockFetch.mockResolvedValue( new Response("Still rate limited", { status: 429, headers: { "Retry-After": "2" }, }) ); // First, trigger a 429 response to set throttling state const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "2" }, }), }); // Fast-forward through all retry attempts (3 retries) for (let i = 0; i < 3; i++) { vi.advanceTimersByTime(2000); await vi.runAllTimersAsync(); } await onResponsePromise; // Now try a second request - it should be delayed const secondRequest = new Request( "https://api.revenuecat.com/v1/subscribers" ); const onRequestPromise = middleware.onRequest?.( createMockCallbackParams(secondRequest) ); // Fast-forward time by 1 second (less than the 2 second retry-after) vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); // The request should still be pending expect(onRequestPromise).toBeInstanceOf(Promise); // Fast-forward to complete the delay vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onRequestPromise; expect(result).toBeUndefined(); }); it("should not warn when queue is empty", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Call onRequest - should not warn since queue is empty await middleware.onRequest?.(createMockCallbackParams(request)); expect(mockConsoleWarn).not.toHaveBeenCalled(); }); }); describe("onResponse", () => { it("should return response unchanged for non-429 responses", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); const response = new Response("Success", { status: 200 }); const result = await middleware.onResponse?.({ ...createMockCallbackParams(request), response, }); expect(result).toBeUndefined(); }); it("should retry 429 responses after waiting for Retry-After time and return success if retry succeeds", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock the retry response to be successful mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 })); const response429 = new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "1" }, }); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Fast-forward time to complete the retry-after delay vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onResponsePromise; expect(mockFetch).toHaveBeenCalledWith(request, {}); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(200); }); it("should return original 429 response if all retries fail with 429", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock all retries to return 429 mockFetch.mockResolvedValue( new Response("Still rate limited", { status: 429, headers: { "Retry-After": "1" }, }) ); const originalResponse = new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "1" }, }); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: originalResponse, }); // Fast-forward through all retry attempts for (let i = 0; i < 3; i++) { vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); } const result = await onResponsePromise; // Should have called fetch 3 times (the retry attempts) expect(mockFetch).toHaveBeenCalledTimes(3); // Should return the last 429 response after max retries expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(429); }); it("should handle missing Retry-After header with fallback and succeed if retry does", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock successful retry mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 })); const response429 = new Response("Rate limit exceeded", { status: 429, // No Retry-After header }); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Fast-forward time by 1 second (fallback delay) vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onResponsePromise; expect(mockFetch).toHaveBeenCalledWith(request, {}); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(200); }); it("should handle invalid Retry-After header with fallback and succeed if retry does", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock successful retry mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 })); const response429 = new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "invalid" }, }); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Fast-forward time by 1 second (fallback delay) vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onResponsePromise; expect(mockFetch).toHaveBeenCalledWith(request, {}); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(200); }); it("should handle fetch errors during retry and throw", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock fetch to throw an error mockFetch.mockRejectedValueOnce(new Error("Network error")); const response429 = new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "1" }, }); let caughtError: Error | undefined; const onResponsePromise = ( middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }) as Promise<Response | undefined> ).catch((error) => { // Work around Vitest issue catching unhandled rejections caughtError = error; }); // Fast-forward time to complete the retry-after delay vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); await onResponsePromise; expect(caughtError).toBeInstanceOf(Error); expect(caughtError?.message).toBe("Network error"); }); it("should not retry when response body has retryable: false", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); const response429 = new Response( JSON.stringify({ type: "rate_limit_error", message: "Rate limit exceeded", retryable: false, doc_url: "https://errors.rev.cat/rate-limit-error", backoff_ms: 1000, }), { status: 429, headers: { "Retry-After": "1", "Content-Type": "application/json" }, } ); const result = await middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Should return the original response without retrying expect(result).toBe(response429); expect(mockFetch).not.toHaveBeenCalled(); }); it("should retry when response body has retryable: true", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock successful retry mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 })); const response429 = new Response( JSON.stringify({ type: "rate_limit_error", message: "Rate limit exceeded", retryable: true, doc_url: "https://errors.rev.cat/rate-limit-error", backoff_ms: 1000, }), { status: 429, headers: { "Retry-After": "1", "Content-Type": "application/json" }, } ); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Fast-forward time to complete the retry-after delay vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onResponsePromise; expect(mockFetch).toHaveBeenCalledWith(request, {}); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(200); }); it("should retry when response body has no retryable field", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock successful retry mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 })); const response429 = new Response( JSON.stringify({ type: "rate_limit_error", message: "Rate limit exceeded", doc_url: "https://errors.rev.cat/rate-limit-error", backoff_ms: 1000, }), { status: 429, headers: { "Retry-After": "1", "Content-Type": "application/json" }, } ); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Fast-forward time to complete the retry-after delay vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onResponsePromise; expect(mockFetch).toHaveBeenCalledWith(request, {}); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(200); }); it("should retry when response body is not valid JSON", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock successful retry mockFetch.mockResolvedValueOnce(new Response("Success", { status: 200 })); const response429 = new Response("Invalid JSON response", { status: 429, headers: { "Retry-After": "1" }, }); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Fast-forward time to complete the retry-after delay vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onResponsePromise; expect(mockFetch).toHaveBeenCalledWith(request, {}); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(200); }); it("should stop retrying when retry response has retryable: false", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock retry response to return 429 with retryable: false mockFetch.mockResolvedValueOnce( new Response( JSON.stringify({ type: "rate_limit_error", message: "Rate limit exceeded", retryable: false, doc_url: "https://errors.rev.cat/rate-limit-error", backoff_ms: 1000, }), { status: 429, headers: { "Retry-After": "1", "Content-Type": "application/json" }, } ) ); const response429 = new Response( JSON.stringify({ type: "rate_limit_error", message: "Rate limit exceeded", retryable: true, doc_url: "https://errors.rev.cat/rate-limit-error", backoff_ms: 1000, }), { status: 429, headers: { "Retry-After": "1", "Content-Type": "application/json" }, } ); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: response429, }); // Fast-forward time to complete the retry-after delay vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); const result = await onResponsePromise; // Should have called fetch once for the retry expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith(request, {}); // Should return the retry response (429 with retryable: false) expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(429); }); }); describe("endpoint-specific throttling", () => { it("should throttle different endpoints independently", async () => { const middleware = createRateLimitMiddleware(); const request1 = new Request("https://api.revenuecat.com/v1/subscribers"); const request2 = new Request("https://api.revenuecat.com/v1/products"); // Mock all retries to return 429 mockFetch.mockResolvedValue( new Response("Still rate limited", { status: 429, headers: { "Retry-After": "2" }, }) ); // Throttle the first endpoint const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request1), response: new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "2" }, }), }); // Fast-forward through all retry attempts (3 retries) for (let i = 0; i < 3; i++) { vi.advanceTimersByTime(2000); await vi.runAllTimersAsync(); } await onResponsePromise; // The second endpoint should not be affected const result = await middleware.onRequest?.( createMockCallbackParams(request2) ); expect(result).toBeUndefined(); }); it("should use method and pathname for endpoint key", async () => { const middleware = createRateLimitMiddleware(); // These should be treated as different endpoints const getRequest = new Request( "https://api.revenuecat.com/v1/subscribers" ); const postRequest = new Request( "https://api.revenuecat.com/v1/subscribers", { method: "POST" } ); // Mock all retries to return 429 mockFetch.mockResolvedValue( new Response("Still rate limited", { status: 429, headers: { "Retry-After": "2" }, }) ); // Throttle GET endpoint const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(getRequest), response: new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "2" }, }), }); // Fast-forward through all retry attempts (3 retries) for (let i = 0; i < 3; i++) { vi.advanceTimersByTime(2000); await vi.runAllTimersAsync(); } await onResponsePromise; // POST endpoint should not be affected const result = await middleware.onRequest?.( createMockCallbackParams(postRequest) ); expect(result).toBeUndefined(); }); }); describe("multiple retries", () => { it("should return 429 response without retrying", async () => { const middleware = createRateLimitMiddleware(); const request = new Request("https://api.revenuecat.com/v1/subscribers"); // Mock all retries to return 429 mockFetch.mockResolvedValue( new Response("Still rate limited", { status: 429, headers: { "Retry-After": "1" }, }) ); const originalResponse = new Response("Rate limit exceeded", { status: 429, headers: { "Retry-After": "1" }, }); const onResponsePromise = middleware.onResponse?.({ ...createMockCallbackParams(request), response: originalResponse, }); // Fast-forward through all retry attempts for (let i = 0; i < 3; i++) { vi.advanceTimersByTime(1000); await vi.runAllTimersAsync(); } const result = await onResponsePromise; // Should have called fetch 3 times (the retry attempts) expect(mockFetch).toHaveBeenCalledTimes(3); // Should return the last 429 response after max retries expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(429); }); }); describe("concurrent requests", () => { it("should handle multiple concurrent requests to the same endpoint", async () => { const middleware = createRateLimitMiddleware(); const baseUrl = "https://api.revenuecat.com/v1/subscribers"; // Create multiple requests const requests = Array.from( { length: 5 }, (_, i) => new Request(`${baseUrl}?id=${i}`) ); // Start all requests - they should all complete immediately since no throttling const onRequestPromises = requests.map((request) => middleware.onRequest?.(createMockCallbackParams(request)) ); // All requests should complete immediately const results = await Promise.all(onRequestPromises); results.forEach((result) => { expect(result).toBeUndefined(); }); }); }); });