From a3007a3a81bffb6051e321ee4fb08e7379b23279 Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 02:44:45 +0000 Subject: [PATCH 1/3] Add HttpClient utility for frontend --- assets/js/utils/http-client.ts | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 assets/js/utils/http-client.ts diff --git a/assets/js/utils/http-client.ts b/assets/js/utils/http-client.ts new file mode 100644 index 00000000..a41e1f35 --- /dev/null +++ b/assets/js/utils/http-client.ts @@ -0,0 +1,96 @@ +import { retry } from './retry'; + +interface RequestParams extends RequestInit { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + query?: Record; + headers?: Record; +} + +export class HttpError extends Error { + response: Response; + + constructor(request: Request, response: Response) { + super(`${request.method} ${request.url} request failed (${response.status}: ${response.statusText})`); + this.response = response; + } +} + +/** + * Generic HTTP Client with some batteries included: + * + * - Handles rendering of the URL with query parameters + * - Throws an error on non-OK responses + * - Automatically retries failed requests + * - Add some useful meta headers + * - ...Some other method-specific goodies + */ +export class HttpClient { + // There isn't any state in this class at the time of this writing, but + // we may add some in the future to allow for more advanced base configuration. + + /** + * Issues a request, expecting a JSON response. + */ + async fetchJson(path: string, params?: RequestParams): Promise { + const response = await this.fetch(path, params); + return response.json(); + } + + async fetch(path: string, params: RequestParams = {}): Promise { + const url = new URL(path, window.location.origin); + + for (const [key, value] of Object.entries(params.query ?? {})) { + url.searchParams.set(key, value); + } + + params.headers ??= {}; + + // This header serves as an idempotency token that identifies the sequence + // of retries of the same request. The backend may use this information to + // ensure that the same retried request doesn't result in multiple accumulated + // side-effects. + params.headers['X-Retry-Sequence-Id'] = generateId('rs-'); + + return retry( + async (attempt: number) => { + params.headers!['X-Request-Id'] = generateId('req-'); + params.headers!['X-Retry-Attempt'] = String(attempt); + + const request = new Request(url, params); + + const response = await fetch(request); + + if (!response.ok) { + throw new HttpError(request, response); + } + + return response; + }, + { isRetryable, label: `HTTP ${params.method ?? 'GET'} ${url}` }, + ); + } +} + +function isRetryable(error: Error): boolean { + return error instanceof HttpError && error.response.status >= 500; +} + +/** + * Generates a base32 ID with the given prefix as the ID discriminator. + * The prefix is useful when reading or grepping thru logs to identify the type + * of the ID (i.e. it's visually clear that strings that start with `req-` are + * request IDs). + */ +function generateId(prefix: string) { + // Base32 alphabet without any ambiguous characters. + // (details: https://github.com/maksverver/key-encoding#eliminating-ambiguous-characters) + const alphabet = '23456789abcdefghjklmnpqrstuvwxyz'; + + const chars = [prefix]; + + for (let i = 0; i < 10; i++) { + chars.push(alphabet[Math.floor(Math.random() * alphabet.length)]); + } + + return chars.join(''); +} From c6bc3b379897257310c842b490c3757d84e5ac1f Mon Sep 17 00:00:00 2001 From: MareStare Date: Tue, 4 Mar 2025 02:45:21 +0000 Subject: [PATCH 2/3] Add tests for the `HttpClient` --- assets/js/utils/__tests__/http-client.spec.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 assets/js/utils/__tests__/http-client.spec.ts diff --git a/assets/js/utils/__tests__/http-client.spec.ts b/assets/js/utils/__tests__/http-client.spec.ts new file mode 100644 index 00000000..870c5e87 --- /dev/null +++ b/assets/js/utils/__tests__/http-client.spec.ts @@ -0,0 +1,58 @@ +import { HttpClient } from '../http-client'; +import { fetchMock } from '../../../test/fetch-mock'; + +describe('HttpClient', () => { + beforeAll(() => { + vi.useFakeTimers(); + fetchMock.enableMocks(); + }); + + afterEach(() => { + fetchMock.resetMocks(); + }); + + it('should throw an HttpError on non-OK responses', async () => { + const client = new HttpClient(); + + fetchMock.mockResponse('Not Found', { status: 404, statusText: 'Not Found' }); + + await expect(client.fetch('/', {})).rejects.toThrowError(/404: Not Found/); + + // 404 is non-retryable + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('should retry 500 errors', async () => { + const client = new HttpClient(); + + fetchMock.mockResponses( + ['Internal Server Error', { status: 500, statusText: 'Internal Server Error' }], + ['OK', { status: 200, statusText: 'OK' }], + ); + + const promise = expect(client.fetch('/', {})).resolves.toMatchObject({ status: 200, statusText: 'OK' }); + await vi.runAllTimersAsync(); + await promise; + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('should not retry AbortError', async () => { + const client = new HttpClient(); + + fetchMock.mockResponse('OK', { status: 200, statusText: 'OK' }); + + const abortController = new AbortController(); + + const promise = expect(client.fetch('/', { signal: abortController.signal })).rejects.toThrowError( + 'The operation was aborted.', + ); + + abortController.abort(); + + await vi.runAllTimersAsync(); + await promise; + + expect(fetch).toHaveBeenCalledOnce(); + }); +}); From 39b2a3a1c012a48a62598598563f24942e882053 Mon Sep 17 00:00:00 2001 From: MareStare Date: Wed, 12 Mar 2025 00:24:27 +0000 Subject: [PATCH 3/3] Ignore non-100% coverage for HttpClient until 453 PR is merged --- assets/js/utils/http-client.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/js/utils/http-client.ts b/assets/js/utils/http-client.ts index a41e1f35..d04df180 100644 --- a/assets/js/utils/http-client.ts +++ b/assets/js/utils/http-client.ts @@ -1,3 +1,6 @@ +// Ignoring a non-100% coverage for HTTP client for now. +// It will be 100% in https://github.com/philomena-dev/philomena/pull/453 +/* v8 ignore start */ import { retry } from './retry'; interface RequestParams extends RequestInit { @@ -94,3 +97,4 @@ function generateId(prefix: string) { return chars.join(''); } +/* v8 ignore end */