242 lines
8.5 KiB
TypeScript
242 lines
8.5 KiB
TypeScript
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<string, string> = {}
|
|
): 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<Response> =>
|
|
new Promise<Response>((_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<typeof vi.fn>;
|
|
|
|
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('<html>maintenance</html>', { 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();
|
|
});
|
|
});
|