TryGhost/Ghost

View on GitHub
apps/admin-x-framework/src/utils/api/fetchApi.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import * as Sentry from '@sentry/react';
import {useFramework} from '../../providers/FrameworkProvider';
import {APIError, MaintenanceError, ServerUnreachableError, TimeoutError} from '../errors';
import {getGhostPaths} from '../helpers';
import handleResponse from './handleResponse';

export interface RequestOptions {
    method?: string;
    body?: string | FormData;
    headers?: {
        'Content-Type'?: string;
    };
    credentials?: 'include' | 'omit' | 'same-origin';
    timeout?: number;
    retry?: boolean;
}

export const useFetchApi = () => {
    const {ghostVersion, sentryDSN} = useFramework();

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return async <ResponseData = any>(endpoint: string | URL, {headers = {}, retry, ...options}: RequestOptions = {}): Promise<ResponseData> => {
        // By default, we set the Content-Type header to application/json
        const defaultHeaders: Record<string, string> = {
            'app-pragma': 'no-cache',
            'x-ghost-version': ghostVersion
        };
        if (typeof options.body === 'string') {
            defaultHeaders['content-type'] = 'application/json';
        }

        const controller = new AbortController();
        const {timeout} = options;

        if (timeout) {
            setTimeout(() => controller.abort(), timeout);
        }

        // attempt retries for 15 seconds in two situations:
        // 1. Server Unreachable error from the browser (code 0 or TypeError), typically from short internet blips
        // 2. Maintenance error from Ghost, upgrade in progress so API is temporarily unavailable
        let attempts = 0;
        const shouldRetry = retry !== false;
        let retryingMs = 0;
        const startTime = Date.now();
        const maxRetryingMs = 15_000;
        const retryPeriods = [500, 1000];
        const retryableErrors = [ServerUnreachableError, MaintenanceError, TypeError];

        const getErrorData = (error?: APIError, response?: Response) => {
            const data: Record<string, unknown> = {
                errorName: error?.name,
                attempts,
                totalSeconds: retryingMs / 1000,
                endpoint: endpoint.toString()
            };
            if (endpoint.toString().includes('/ghost/api/')) {
                data.server = response?.headers.get('server');
            }
            return data;
        };

        while (attempts === 0 || shouldRetry) {
            try {
                const response = await fetch(endpoint, {
                    headers: {
                        ...defaultHeaders,
                        ...headers
                    },
                    method: 'GET',
                    mode: 'cors',
                    credentials: 'include',
                    signal: controller.signal,
                    ...options
                });

                return handleResponse(response) as ResponseData;
            } catch (error) {
                retryingMs = Date.now() - startTime;

                if (shouldRetry && (import.meta.env.MODE !== 'development' && retryableErrors.some(errorClass => error instanceof errorClass) && retryingMs <= maxRetryingMs)) {
                    await new Promise((resolve) => {
                        setTimeout(resolve, retryPeriods[attempts] || retryPeriods[retryPeriods.length - 1]);
                    });
                    attempts += 1;
                    continue;
                }

                if (attempts !== 0 && sentryDSN) {
                    Sentry.captureMessage('Request failed after multiple attempts', {extra: getErrorData()});
                }

                if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
                    throw new TimeoutError();
                }

                let newError = error;

                if (!(error instanceof APIError)) {
                    newError = new ServerUnreachableError({cause: error});
                }

                throw newError;
            };
        }

        // Used for type checking
        // this can't happen, but TS isn't smart enough to undeerstand that the loop will never exit without an error or return
        // because of shouldRetry + attemps usage combination
        return undefined as never;
    };
};

const {apiRoot} = getGhostPaths();

export const apiUrl = (path: string, searchParams: Record<string, string> = {}) => {
    const url = new URL(`${apiRoot}${path}`, window.location.origin);
    url.search = new URLSearchParams(searchParams).toString();
    return url.toString();
};