import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { requestJson, isHttpRequestError, parseRetryAfter } from '../lib/httpClient.js'; const URL_UNDER_TEST = 'https://api.example.com/v1/token'; const jsonResponse = ( body: unknown, status = 200, headers: Record = {} ): Response => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers }, }); /** Never settles on its own; only the per-attempt timeout or the caller's signal ends it. */ const hangUntilAborted = (init?: RequestInit): Promise => new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => { reject(init.signal?.reason ?? new Error('aborted')); }); }); // Backoff is kept at a single millisecond so retries are asserted by call count, never by clock. const fast = { maxAttempts: 3, baseDelayMs: 1 } as const; describe('requestJson', () => { let fetchMock: ReturnType; beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); it('should resolve and parse a 200 JSON body', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: 'abc', expires_in: 3600 })); const result = await requestJson<{ access_token: string; expires_in: number }>( URL_UNDER_TEST, fast ); expect(result).toEqual({ access_token: 'abc', expires_in: 3600 }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should retry a 429 with a small Retry-After and then succeed', async () => { fetchMock .mockResolvedValueOnce(jsonResponse({ error: 'slow down' }, 429, { 'retry-after': '0' })) .mockResolvedValueOnce(jsonResponse({ ok: true })); const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, fast); expect(result).toEqual({ ok: true }); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should honor an HTTP-date Retry-After on a 429', async () => { const httpDate = new Date(Date.now() + 500).toUTCString(); fetchMock .mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': httpDate })) .mockResolvedValueOnce(jsonResponse({ ok: true })); const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, fast); expect(result).toEqual({ ok: true }); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should retry a 500 up to maxAttempts and then throw', async () => { fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ error: 'boom' }, 500))); await expect(requestJson(URL_UNDER_TEST, fast)).rejects.toThrow(/status 500/); expect(fetchMock).toHaveBeenCalledTimes(3); }); it('should throw immediately on a 400 without retrying', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 400)); await expect(requestJson(URL_UNDER_TEST, fast)).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should throw immediately on a 401 without retrying', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'unauthorized' }, 401)); await expect(requestJson(URL_UNDER_TEST, fast)).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should treat a per-attempt timeout as retryable', async () => { fetchMock .mockImplementationOnce((_url: string, init?: RequestInit) => hangUntilAborted(init)) .mockResolvedValueOnce(jsonResponse({ ok: true })); const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, { ...fast, timeoutMs: 1, }); expect(result).toEqual({ ok: true }); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should exhaust attempts when every attempt times out', async () => { fetchMock.mockImplementation((_url: string, init?: RequestInit) => hangUntilAborted(init)); const error = await requestJson(URL_UNDER_TEST, { ...fast, maxAttempts: 2, timeoutMs: 1 }) .then(() => null) .catch((err: unknown) => err); expect(isHttpRequestError(error)).toBe(true); expect(isHttpRequestError(error) && error.kind).toBe('timeout'); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should identify the url, status and attempt count in the thrown message', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 400)); const error = await requestJson(URL_UNDER_TEST, fast) .then(() => null) .catch((err: unknown) => err); expect(isHttpRequestError(error)).toBe(true); const message = error instanceof Error ? error.message : ''; expect(message).toContain(URL_UNDER_TEST); expect(message).toContain('status 400'); expect(message).toContain('1 attempt(s)'); expect(isHttpRequestError(error) && error.status).toBe(400); }); it('should distinguish an HTTP failure from a programming error', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({}, 400)); const httpError = await requestJson(URL_UNDER_TEST, fast) .then(() => null) .catch((err: unknown) => err); expect(isHttpRequestError(httpError)).toBe(true); expect(isHttpRequestError(new TypeError('bad call'))).toBe(false); }); it('should reject a 2xx whose body is not valid JSON', async () => { fetchMock.mockResolvedValueOnce(new Response('maintenance', { status: 200 })); const error = await requestJson(URL_UNDER_TEST, fast) .then(() => null) .catch((err: unknown) => err); expect(isHttpRequestError(error) && error.kind).toBe('invalid-body'); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should resolve undefined for a bodiless 204', async () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); await expect(requestJson(URL_UNDER_TEST, fast)).resolves.toBeUndefined(); }); it('should retry a transport error and then succeed', async () => { fetchMock .mockRejectedValueOnce(new TypeError('fetch failed')) .mockResolvedValueOnce(jsonResponse({ ok: true })); const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, fast); expect(result).toEqual({ ok: true }); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('should not call fetch when the caller signal is already aborted', async () => { fetchMock.mockResolvedValue(jsonResponse({ ok: true })); const error = await requestJson(URL_UNDER_TEST, { ...fast, signal: AbortSignal.abort() }) .then(() => null) .catch((err: unknown) => err); expect(isHttpRequestError(error) && error.kind).toBe('aborted'); expect(fetchMock).not.toHaveBeenCalled(); }); it('should abort without retrying when the caller signal fires mid-flight', async () => { const controller = new AbortController(); fetchMock.mockImplementation((_url: string, init?: RequestInit) => { queueMicrotask(() => controller.abort()); return hangUntilAborted(init); }); const error = await requestJson(URL_UNDER_TEST, { ...fast, signal: controller.signal }) .then(() => null) .catch((err: unknown) => err); expect(isHttpRequestError(error) && error.kind).toBe('aborted'); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should reject a non-positive maxAttempts', async () => { await expect(requestJson(URL_UNDER_TEST, { maxAttempts: 0 })).rejects.toThrow(TypeError); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe('parseRetryAfter', () => { it('should read the delay-seconds form', () => { expect(parseRetryAfter('30')).toBe(30_000); expect(parseRetryAfter('0')).toBe(0); }); it('should read the HTTP-date form as a delay from now', () => { const delay = parseRetryAfter(new Date(Date.now() + 30_000).toUTCString()); expect(delay).not.toBeNull(); expect(delay).toBeGreaterThan(25_000); expect(delay).toBeLessThanOrEqual(30_000); }); it('should clamp a past HTTP-date to zero', () => { expect(parseRetryAfter(new Date(Date.now() - 60_000).toUTCString())).toBe(0); }); it('should cap a hostile delay-seconds value', () => { expect(parseRetryAfter('86400')).toBe(60_000); }); it('should cap a hostile HTTP-date value', () => { expect(parseRetryAfter(new Date(Date.now() + 86_400_000).toUTCString())).toBe(60_000); }); it('should return null for a missing or unintelligible header', () => { expect(parseRetryAfter(null)).toBeNull(); expect(parseRetryAfter(' ')).toBeNull(); expect(parseRetryAfter('soon')).toBeNull(); }); });