diff --git a/src/services/common/rate-limit/rate-limit.retry.spec.ts b/src/services/common/rate-limit/rate-limit.retry.spec.ts new file mode 100644 index 00000000..a8c14cc7 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.retry.spec.ts @@ -0,0 +1,177 @@ +import { withRateLimitRetry } from './rate-limit.retry'; +import { HTTP_TOO_MANY_REQUESTS, MAX_RATE_LIMIT_RETRIES, rateLimitService } from './rate-limit.service'; + +jest.mock('@internxt-mobile/services/common', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const { logger } = jest.requireMock('@internxt-mobile/services/common'); + +const make429Error = (status = HTTP_TOO_MANY_REQUESTS) => ({ status, message: 'Too Many Requests' }); + +describe('withRateLimitRetry', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(rateLimitService, 'getRetryDelay').mockReturnValue(1000); + (logger.warn as jest.Mock).mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('when operation succeeds on first try, then returns the result without retrying', async () => { + const operation = jest.fn().mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'test-context'); + await jest.advanceTimersByTimeAsync(0); + const result = await promise; + + expect(result).toBe('ok'); + expect(operation).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('when operation fails with 429 then succeeds, then retries and returns the result', async () => { + const operation = jest.fn().mockRejectedValueOnce(make429Error()).mockResolvedValue('recovered'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toBe('recovered'); + expect(operation).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('when operation fails with non-429 error, then throws immediately without retrying', async () => { + const nonRateLimitError = { status: 500, message: 'Server Error' }; + const operation = jest.fn().mockRejectedValue(nonRateLimitError); + + const promise = withRateLimitRetry(operation, 'test-context'); + await expect(promise).rejects.toEqual(nonRateLimitError); + expect(operation).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('when operation fails with error without status, then throws immediately', async () => { + const plainError = new Error('network failure'); + const operation = jest.fn().mockRejectedValue(plainError); + + const promise = withRateLimitRetry(operation, 'test-context'); + await expect(promise).rejects.toThrow('network failure'); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it(`when operation fails with 429 ${MAX_RATE_LIMIT_RETRIES} times, then exhausts retries and throws`, async () => { + const error429 = make429Error(); + const operation = jest.fn().mockRejectedValue(error429); + + let caughtError: unknown; + const promise = withRateLimitRetry(operation, 'upload').catch((err) => { + caughtError = err; + }); + + for (let i = 0; i < MAX_RATE_LIMIT_RETRIES; i++) { + await jest.advanceTimersByTimeAsync(1000); + } + + await promise; + expect(caughtError).toEqual(error429); + expect(operation).toHaveBeenCalledTimes(MAX_RATE_LIMIT_RETRIES + 1); + expect(logger.warn).toHaveBeenCalledTimes(MAX_RATE_LIMIT_RETRIES); + }); + + it('when operation fails with 429 twice then succeeds, then returns the result', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(make429Error()) + .mockRejectedValueOnce(make429Error()) + .mockResolvedValue('third-time-charm'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toBe('third-time-charm'); + expect(operation).toHaveBeenCalledTimes(3); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it('when endpointKey is provided, then passes it to getRetryDelay', async () => { + const operation = jest.fn().mockRejectedValueOnce(make429Error()).mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'upload', 'https://gw.internxt.com/drive/files'); + await jest.advanceTimersByTimeAsync(1000); + await promise; + + expect(rateLimitService.getRetryDelay).toHaveBeenCalledWith(undefined, 'https://gw.internxt.com/drive/files'); + }); + + it('when endpointKey is not provided, then passes undefined to getRetryDelay', async () => { + const operation = jest.fn().mockRejectedValueOnce(make429Error()).mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(1000); + await promise; + + expect(rateLimitService.getRetryDelay).toHaveBeenCalledWith(undefined, undefined); + }); + + it('when retrying, then logs the correct context and retry count', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(make429Error()) + .mockRejectedValueOnce(make429Error()) + .mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'file-upload'); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + await promise; + + expect(logger.warn).toHaveBeenCalledWith( + `[RateLimit] file-upload 429, retry 1/${MAX_RATE_LIMIT_RETRIES} after 1000ms`, + ); + expect(logger.warn).toHaveBeenCalledWith( + `[RateLimit] file-upload 429, retry 2/${MAX_RATE_LIMIT_RETRIES} after 1000ms`, + ); + }); + + it('when getRetryDelay returns different values per call, then uses the correct delay each time', async () => { + (rateLimitService.getRetryDelay as jest.Mock).mockReturnValueOnce(500).mockReturnValueOnce(2000); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + const operation = jest + .fn() + .mockRejectedValueOnce(make429Error()) + .mockRejectedValueOnce(make429Error()) + .mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(500); + await jest.advanceTimersByTimeAsync(2000); + await promise; + + const delayCalls = setTimeoutSpy.mock.calls + .filter((args) => typeof args[1] === 'number' && args[1] > 0) + .map((args) => args[1]); + expect(delayCalls).toEqual([500, 2000]); + }); + + it('when operation returns a typed result, then preserves the type', async () => { + const operation = jest.fn().mockResolvedValue({ id: 1, name: 'test' }); + + const promise = withRateLimitRetry(operation, 'typed'); + await jest.advanceTimersByTimeAsync(0); + const result = await promise; + + expect(result).toEqual({ id: 1, name: 'test' }); + }); +}); diff --git a/src/services/common/rate-limit/rate-limit.service.spec.ts b/src/services/common/rate-limit/rate-limit.service.spec.ts new file mode 100644 index 00000000..42660195 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.service.spec.ts @@ -0,0 +1,513 @@ +import { extractEndpointKey, rateLimitService } from './rate-limit.service'; + +const makeHeaders = (limit: number, remaining: number, reset: number): Record => ({ + 'x-internxt-ratelimit-limit': String(limit), + 'x-internxt-ratelimit-remaining': String(remaining), + 'x-internxt-ratelimit-reset': String(reset), +}); + +describe('extractEndpointKey', () => { + describe('when joining baseURL and url', () => { + it('when url starts with /, then joins correctly', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/auth/login', + }); + expect(result).toBe('https://gateway.internxt.com/drive/auth/login'); + }); + + it('when url does NOT start with /, then adds separator', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: 'folders/content/some-id/files', + }); + expect(result).toContain('/drive/folders/'); + }); + + it('when baseURL ends with / and url starts with /, then does not double slash', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive/', + url: '/files/recents', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files/recents'); + }); + + it('when only url is provided (direct axios), then uses full url', () => { + const result = extractEndpointKey({ + url: 'https://gateway.internxt.com/payments/display-billing', + }); + expect(result).toBe('https://gateway.internxt.com/payments/display-billing'); + }); + + it('when both are empty, then returns unknown', () => { + expect(extractEndpointKey({})).toBe('unknown'); + expect(extractEndpointKey({ baseURL: '', url: '' })).toBe('unknown'); + }); + }); + + describe('when normalizing UUID path segments', () => { + it('when path contains a UUID, then replaces with :id', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/folders/c6fe170d-f348-63c1-7343-0633abcdef12/content', + }); + expect(result).toBe('https://gateway.internxt.com/drive/folders/:id/content'); + }); + + it('when path contains multiple UUIDs, then replaces all', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/folders/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/files/11111111-2222-3333-4444-555555555555', + }); + expect(result).toBe('https://gateway.internxt.com/drive/folders/:id/files/:id'); + }); + }); + + describe('when normalizing hex ID path segments (MongoDB ObjectIDs)', () => { + it('when path has a 24-char hex id, then replaces with :id', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files', + }); + expect(result).toBe('https://gateway.internxt.com/network/buckets/:id/files'); + }); + + it('when path has multiple hex ids, then replaces all', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/c5bf086c78a0ada492d1f/info', + }); + expect(result).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + + it('when hex id starts with digits, then replaces the full segment', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/5bf086c78a0ada492d1f/info', + }); + expect(result).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + }); + + describe('when normalizing numeric path segments', () => { + it('when path has a pure numeric segment, then replaces with :id', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/files/12345/info', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files/:id/info'); + }); + + it('when path has mixed text and numbers, then does NOT replace', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/files/recents', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files/recents'); + }); + }); + + describe('when handling query parameters', () => { + it('when url has query params, then strips them', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/files?limit=50&offset=0', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files'); + }); + }); + + describe('when handling real endpoints from logs', () => { + it('drive auth login', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/auth/login' })).toBe( + 'https://gateway.internxt.com/drive/auth/login', + ); + }); + + it('drive users usage', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/users/usage' })).toBe( + 'https://gateway.internxt.com/drive/users/usage', + ); + }); + + it('drive users limit', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/users/limit' })).toBe( + 'https://gateway.internxt.com/drive/users/limit', + ); + }); + + it('drive files recents', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/files/recents' })).toBe( + 'https://gateway.internxt.com/drive/files/recents', + ); + }); + + it('drive folders content with uuid', () => { + expect( + extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/folders/content/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/files', + }), + ).toBe('https://gateway.internxt.com/drive/folders/content/:id/files'); + }); + + it('drive sharings (url without leading /)', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: 'sharings/folders' })).toBe( + 'https://gateway.internxt.com/drive/sharings/folders', + ); + }); + + it('network buckets with hex ids', () => { + expect( + extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/a73b3f5dfe500088647b6/info', + }), + ).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + + it('network buckets with digit-starting hex ids', () => { + expect( + extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/65c5bf086c78a0ada492d1f/info', + }), + ).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + }); + + describe('when short hex words appear in paths', () => { + it('when a segment is a known word like "cafe", then does NOT replace', () => { + const result = extractEndpointKey({ + baseURL: 'https://example.com', + url: '/api/dead/beef', + }); + expect(result).toBe('https://example.com/api/dead/beef'); + }); + }); +}); + +describe('RateLimitService', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('updateFromHeaders', () => { + it('when all rate limit headers are present, then stores state for the endpoint', () => { + const endpoint = 'update-all-headers'; + rateLimitService.updateFromHeaders(makeHeaders(200, 180, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when limit header is missing, then does not store state', () => { + const endpoint = 'update-missing-limit'; + rateLimitService.updateFromHeaders({ 'x-internxt-ratelimit-remaining': '100', 'x-internxt-ratelimit-reset': '1000' }, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when remaining header is missing, then does not store state', () => { + const endpoint = 'update-missing-remaining'; + rateLimitService.updateFromHeaders({ 'x-internxt-ratelimit-limit': '200', 'x-internxt-ratelimit-reset': '1000' }, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when reset header is missing, then does not store state', () => { + const endpoint = 'update-missing-reset'; + rateLimitService.updateFromHeaders({ 'x-internxt-ratelimit-limit': '200', 'x-internxt-ratelimit-remaining': '100' }, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when header values are non-numeric, then does not store state', () => { + const endpoint = 'update-non-numeric'; + rateLimitService.updateFromHeaders( + { 'x-internxt-ratelimit-limit': 'abc', 'x-internxt-ratelimit-remaining': '100', 'x-internxt-ratelimit-reset': '1000' }, + endpoint, + ); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when two endpoints receive headers, then states are independent', () => { + const endpointA = 'update-independent-a'; + const endpointB = 'update-independent-b'; + const futureReset = Date.now() + 60000; + + rateLimitService.updateFromHeaders(makeHeaders(200, 10, futureReset), endpointA); + rateLimitService.updateFromHeaders(makeHeaders(200, 180, futureReset), endpointB); + + expect(rateLimitService.shouldThrottle(endpointA)).toBe(true); + expect(rateLimitService.shouldThrottle(endpointB)).toBe(false); + }); + }); + + describe('shouldThrottle', () => { + it('when no state exists for the endpoint, then returns false', () => { + expect(rateLimitService.shouldThrottle('throttle-no-state')).toBe(false); + }); + + it('when remaining is above 40% of limit, then returns false', () => { + const endpoint = 'throttle-above'; + rateLimitService.updateFromHeaders(makeHeaders(200, 81, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when remaining equals 40% of limit, then returns false', () => { + const endpoint = 'throttle-equal'; + rateLimitService.updateFromHeaders(makeHeaders(200, 80, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when remaining is below 40% of limit, then returns true', () => { + const endpoint = 'throttle-below'; + rateLimitService.updateFromHeaders(makeHeaders(200, 79, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(true); + }); + + it('when remaining is 0, then returns true', () => { + const endpoint = 'throttle-zero'; + rateLimitService.updateFromHeaders(makeHeaders(200, 0, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(true); + }); + }); + + describe('waitIfNeeded', () => { + it('when no state exists, then returns immediately without sleeping', async () => { + const spy = jest.spyOn(global, 'setTimeout'); + await rateLimitService.waitIfNeeded('wait-no-state'); + + const sleepCalls = spy.mock.calls.filter((args) => typeof args[1] === 'number' && args[1] > 0); + expect(sleepCalls).toHaveLength(0); + }); + + it('when remaining is above threshold, then returns immediately', async () => { + const spy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-above-threshold'; + rateLimitService.updateFromHeaders(makeHeaders(200, 100, Date.now() + 60000), endpoint); + + await rateLimitService.waitIfNeeded(endpoint); + + const sleepCalls = spy.mock.calls.filter((args) => typeof args[1] === 'number' && args[1] > 0); + expect(sleepCalls).toHaveLength(0); + }); + + it('when quota is exhausted, then waits with delay capped at MAX_BACKOFF_MS', async () => { + jest.useFakeTimers(); + const sleepSpy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-exhausted'; + const now = Date.now(); + rateLimitService.updateFromHeaders(makeHeaders(200, 0, now + 10000), endpoint); + + const waitPromise = rateLimitService.waitIfNeeded(endpoint); + jest.advanceTimersByTime(5000); + await waitPromise; + + // timeUntilReset ≈ 10000, delay = min(10000 + 2000, 5000) = 5000 (MAX_BACKOFF_MS) + expect(sleepSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + jest.useRealTimers(); + }); + + it('when quota is low, then waits with proportional delay', async () => { + jest.useFakeTimers(); + const sleepSpy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-low'; + const now = Date.now(); + // remaining=20, timeUntilReset≈10000 → delay = min(10000/20, 2000) = 500ms + rateLimitService.updateFromHeaders(makeHeaders(200, 20, now + 10000), endpoint); + + const waitPromise = rateLimitService.waitIfNeeded(endpoint); + jest.advanceTimersByTime(500); + await waitPromise; + + expect(sleepSpy).toHaveBeenCalledWith(expect.any(Function), 500); + jest.useRealTimers(); + }); + + it('when quota is low but proportional delay exceeds cap, then caps at MAX_THROTTLE_DELAY_MS', async () => { + jest.useFakeTimers(); + const sleepSpy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-low-capped'; + const now = Date.now(); + // remaining=1, timeUntilReset≈60000 → delay = min(60000/1, 2000) = 2000ms + rateLimitService.updateFromHeaders(makeHeaders(200, 1, now + 60000), endpoint); + + const waitPromise = rateLimitService.waitIfNeeded(endpoint); + jest.advanceTimersByTime(2000); + await waitPromise; + + expect(sleepSpy).toHaveBeenCalledWith(expect.any(Function), 2000); + jest.useRealTimers(); + }); + }); + + describe('getRetryDelay', () => { + it('when retry-after header is present and valid, then returns its value in ms', () => { + const delay = rateLimitService.getRetryDelay('30'); + expect(delay).toBe(30000); + }); + + it('when retry-after header is 0, then ignores it and falls back', () => { + const delay = rateLimitService.getRetryDelay('0'); + // 0 is not > 0, so falls back to BASE_BACKOFF_MS + expect(delay).toBe(3000); + }); + + it('when retry-after header is non-numeric, then ignores it and falls back', () => { + const delay = rateLimitService.getRetryDelay('abc'); + expect(delay).toBe(3000); + }); + + it('when endpoint has state with pending reset, then uses time until reset', () => { + const now = 1700000000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'retry-with-state'; + // resetMs in future, timeUntilReset ≈ 3000 → delay = min(3000 + 2000, 5000) = 5000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, now + 3000), endpoint); + + const delay = rateLimitService.getRetryDelay(undefined, endpoint); + expect(delay).toBe(5000); + }); + + it('when endpoint has state with short reset, then returns proportional delay', () => { + const endpoint = 'retry-short-reset'; + const now = Date.now(); + // resetMs in future, timeUntilReset ≈ 1000 → delay = min(1000 + 2000, 5000) = 3000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, now + 1000), endpoint); + + const delay = rateLimitService.getRetryDelay(undefined, endpoint); + expect(delay).toBe(3000); + }); + + it('when no retry-after and no endpoint state, then returns BASE_BACKOFF_MS', () => { + const delay = rateLimitService.getRetryDelay(undefined, 'retry-no-state'); + expect(delay).toBe(3000); + }); + + it('when retry-after header is present, then it takes priority over endpoint state', () => { + const endpoint = 'retry-header-priority'; + rateLimitService.updateFromHeaders(makeHeaders(200, 0, Date.now() + 60000), endpoint); + + const delay = rateLimitService.getRetryDelay('2', endpoint); + expect(delay).toBe(2000); + }); + }); + + describe('parseResetValue (via updateFromHeaders + getRetryDelay)', () => { + it('when reset is epoch seconds (> 1e9), then converts to epoch ms', () => { + const now = 1770190042000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-epoch-seconds'; + // 1770190044 is epoch seconds → resetMs = 1770190044000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 1770190044), endpoint); + + // timeUntilReset = 1770190044000 - 1770190042000 = 2000 + // delay = min(2000 + 2000, 5000) = 4000 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(4000); + }); + + it('when reset is epoch ms (> 1e12), then uses as-is', () => { + const now = 1770190042000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-epoch-ms'; + // 1770190044000 is epoch ms → resetMs = 1770190044000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 1770190044000), endpoint); + + // timeUntilReset = 1770190044000 - 1770190042000 = 2000 + // delay = min(2000 + 2000, 5000) = 4000 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(4000); + }); + + it('when reset is microseconds remaining (> 1e6), then converts to absolute ms', () => { + const now = 1000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-microseconds'; + // 2000000 µs = 2000ms → resetMs = 1000000 + 2000 = 1002000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 2000000), endpoint); + + // timeUntilReset = 1002000 - 1000000 = 2000 + // delay = min(2000 + 2000, 5000) = 4000 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(4000); + }); + + it('when reset is milliseconds remaining (> 1000), then adds to Date.now()', () => { + const now = 1000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-milliseconds'; + // 1500ms remaining → resetMs = 1000000 + 1500 = 1001500 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 1500), endpoint); + + // timeUntilReset = 1001500 - 1000000 = 1500 + // delay = min(1500 + 2000, 5000) = 3500 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(3500); + }); + + it('when reset is seconds remaining (<= 1000), then converts to ms and adds to Date.now()', () => { + const now = 1000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-seconds'; + // 60s → resetMs = 1000000 + 60000 = 1060000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 60), endpoint); + + // timeUntilReset = 1060000 - 1000000 = 60000 + // delay = min(60000 + 2000, 5000) = 5000 (capped) + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(5000); + }); + }); + + describe('parseHeader (via updateFromHeaders)', () => { + it('when headers have string numeric values, then parses them correctly', () => { + const endpoint = 'parse-header-numeric'; + rateLimitService.updateFromHeaders(makeHeaders(1000, 500, Date.now() + 60000), endpoint); + + // remaining 500 is 50% of 1000 → above threshold → no throttle + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when a header value is a float string, then parses only the integer part', () => { + const endpoint = 'parse-header-float'; + rateLimitService.updateFromHeaders( + { 'x-internxt-ratelimit-limit': '200.5', 'x-internxt-ratelimit-remaining': '10.9', 'x-internxt-ratelimit-reset': '60.3' }, + endpoint, + ); + + // parseInt('200.5') = 200, parseInt('10.9') = 10 → 10 < 200*0.4=80 → throttle + expect(rateLimitService.shouldThrottle(endpoint)).toBe(true); + }); + + it('when a header value is empty string, then does not store state', () => { + const endpoint = 'parse-header-empty'; + rateLimitService.updateFromHeaders( + { 'x-internxt-ratelimit-limit': '', 'x-internxt-ratelimit-remaining': '100', 'x-internxt-ratelimit-reset': '1000' }, + endpoint, + ); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when headers are completely missing, then does not store state', () => { + const endpoint = 'parse-header-none'; + rateLimitService.updateFromHeaders({}, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when limit is 0, then stores state but never throttles (0 * threshold = 0)', () => { + const endpoint = 'parse-header-zero-limit'; + rateLimitService.updateFromHeaders(makeHeaders(0, 0, Date.now() + 60000), endpoint); + + // remaining 0 < 0 * 0.4 = 0 → false (not strictly less than) + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + }); +});