mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-03-12 14:40:03 +01:00
Merge pull request #440 from MareStare/feat/frontend-http-client-utility
[Part 5] Add HttpClient utility for frontend
This commit is contained in:
commit
6cb3783914
2 changed files with 158 additions and 0 deletions
58
assets/js/utils/__tests__/http-client.spec.ts
Normal file
58
assets/js/utils/__tests__/http-client.spec.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
100
assets/js/utils/http-client.ts
Normal file
100
assets/js/utils/http-client.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
// 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 {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
query?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<T>(path: string, params?: RequestParams): Promise<T> {
|
||||
const response = await this.fetch(path, params);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetch(path: string, params: RequestParams = {}): Promise<Response> {
|
||||
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('');
|
||||
}
|
||||
/* v8 ignore end */
|
Loading…
Add table
Reference in a new issue