jdalrymple/gitbeaker

View on GitHub
packages/rest/src/Requester.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import type {
  RequestOptions,
  ResourceOptions,
  ResponseBodyTypes,
} from '@gitbeaker/requester-utils';
import {
  GitbeakerRequestError,
  GitbeakerRetryError,
  GitbeakerTimeoutError,
  createRequesterFn,
  getMatchingRateLimiter,
} from '@gitbeaker/requester-utils';

export async function processBody(response: Response): Promise<ResponseBodyTypes> {
  // Split to remove potential charset info from the content type
  const contentType = (response.headers.get('content-type') || '').split(';')[0].trim();

  if (contentType === 'application/json') {
    return response.json().then((v) => v || {});
  }

  if (contentType.startsWith('text/')) {
    return response.text().then((t) => t || '');
  }

  return response.blob();
}

function delay(ms: number) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function parseResponse(response: Response, asStream = false) {
  const { status, headers: rawHeaders } = response;
  const headers = Object.fromEntries(rawHeaders.entries());
  let body: ResponseBodyTypes | null;

  if (asStream) {
    body = response.body;
  } else {
    body = status === 204 ? null : await processBody(response); // eslint-disable-line
  }

  return { body, headers, status };
}

async function throwFailedRequestError(
  request: Request,
  response: Response,
): Promise<GitbeakerRequestError> {
  const content = await response.text();
  const contentType = response.headers.get('Content-Type');
  let description = 'API Request Error';

  if (contentType?.includes('application/json')) {
    const output = JSON.parse(content);

    description = output.message;
  } else {
    description = content;
  }

  throw new GitbeakerRequestError(response.statusText, {
    cause: {
      description,
      request,
      response,
    },
  });
}

function getConditionalMode(endpoint: string) {
  if (endpoint.includes('repository/archive')) return 'same-origin';
  return undefined; // Default is 'cors'
}

export async function defaultRequestHandler(endpoint: string, options?: RequestOptions) {
  const retryCodes = [429, 502];
  const maxRetries = 10;
  const { prefixUrl, asStream, searchParams, rateLimiters, method, ...opts } = options || {};
  const rateLimit = getMatchingRateLimiter(endpoint, rateLimiters, method);
  let lastStatus: number | undefined;
  let baseUrl: string | undefined;

  if (prefixUrl) baseUrl = prefixUrl.endsWith('/') ? prefixUrl : `${prefixUrl}/`;

  const url = new URL(endpoint, baseUrl);

  url.search = searchParams || '';

  // CHECKME: https://github.com/nodejs/undici/issues/1305
  const mode = getConditionalMode(endpoint);

  /* eslint-disable no-await-in-loop */
  for (let i = 0; i < maxRetries; i += 1) {
    const request = new Request(url, { ...opts, method, mode });

    await rateLimit();

    const response = await fetch(request).catch((e) => {
      if (e.name === 'TimeoutError' || e.name === 'AbortError') {
        throw new GitbeakerTimeoutError('Query timeout was reached');
      }

      throw e;
    });

    if (response.ok) return parseResponse(response, asStream);
    if (!retryCodes.includes(response.status)) await throwFailedRequestError(request, response);

    // Retry
    lastStatus = response.status;
    await delay(2 ** i * 0.25);

    // eslint-disable-next-line
    continue;
  }
  /* eslint-enable */

  throw new GitbeakerRetryError(
    `Could not successfully complete this request after ${maxRetries} retries, last status code: ${lastStatus}. ${lastStatus === 429 ? 'Check the applicable rate limits for this endpoint' : 'Verify the status of the endpoint'}.`,
  );
}

export const requesterFn = createRequesterFn(
  (_: ResourceOptions, reqo: RequestOptions) => Promise.resolve(reqo),
  defaultRequestHandler,
);