UNPKG

@seriouslag/httpclient

Version:
315 lines (301 loc) 10.6 kB
class HttpError extends Error { isHttpClientError = true; constructor(message, options) { super(message, options); } } class AbortError extends HttpError { constructor(message, options) { super(message, options); } } class FetchRequest { config; constructor(config) { this.config = config; } async do() { try { const response = await fetch(this.config.url, { method: this.config.method, headers: this.config.headers, body: this.config.data ? JSON.stringify(this.config.data) : undefined, signal: this.config.cancelToken?.signal, }); const headers = {}; response.headers.forEach((value, key) => { headers[key] = value; }); return { headers, data: response.body ? await response.json() : undefined, status: response.status, statusText: response.statusText, }; } catch (error) { // if request is canceled then throw an abort error, keeps the error handling consistent // TODO: check if request was aborted then throw an AbortError if (error instanceof DOMException && error.name === 'AbortError') { throw new AbortError(error.message, { cause: error, }); } throw error; } } } class FetchClientAdaptor { globalHeaders = new Map(); buildRequest(config) { return new FetchRequest(config); } addGlobalApiHeader(header) { this.globalHeaders.set(header.name, header.value); } addGlobalApiHeaders(headers) { headers.forEach((header) => this.addGlobalApiHeader(header)); } } /** Function to determine if a HTTP status code is in the successful range (2XX) */ function getIsSuccessfulHttpStatus(status) { return status >= 200 && status < 300; } /** The default HTTP request strategy. No logic. */ class DefaultHttpRequestStrategy { /** Passthrough request to axios and check response is successful */ async request(request) { const response = await request.do(); this.checkResponseStatus(response); return response; } /** Validates the HTTP response is successful or throws an error */ checkResponseStatus(response) { const isSuccessful = getIsSuccessfulHttpStatus(response.status); if (isSuccessful) { return response; } throw response; } } /** * Sleeps for set amount of time (awaitable setTimeout) * @param milliseconds time to sleep in ms * @returns */ function Sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } /** Retries HTTP requests with a specified backoff strategy until the max retry count. */ class ExponentialBackoffRequestStrategy { options; /** TOO MANY REQUESTS STATUS CODE */ TOO_MANY_REQUESTS_STATUS = 429; delayFirstRequest; maxRetryCount; baseDelay; factor; maxDelay; constructor(options = {}) { this.options = options; const { delayFirstRequest, maxRetryCount, baseDelay, factor, maxDelay } = this.options; this.delayFirstRequest = delayFirstRequest ?? false; this.maxRetryCount = maxRetryCount ?? 5; this.baseDelay = baseDelay ?? 100; this.factor = factor ?? 2; this.maxDelay = maxDelay ?? -1; } async request(request) { let response; let retryCount = 0; let isSuccessfulHttpStatus = false; let isTooManyRequests = false; let isAtRetryLimit = false; let delay = this.baseDelay; do { if (this.getShouldDelay(retryCount)) { await Sleep(delay); } retryCount += 1; response = await request.do(); isSuccessfulHttpStatus = getIsSuccessfulHttpStatus(response.status); isTooManyRequests = response.status === this.TOO_MANY_REQUESTS_STATUS; isAtRetryLimit = this.getIsAtRetryMax(retryCount); delay *= this.factor * retryCount; // set delay to max delay if delay is greater than max delay if (this.maxDelay > -1 && delay > this.maxDelay) { delay = this.maxDelay; } } while (!isSuccessfulHttpStatus && !isTooManyRequests && !isAtRetryLimit); return response; } getIsAtRetryMax(retryCount) { return this.maxRetryCount === 0 ? false : retryCount >= this.maxRetryCount; } getShouldDelay(retryCount) { return retryCount !== 0 || this.delayFirstRequest; } } /** Retries HTTP requests immediately on non successful HTTP request until the max retry count. * Stops retrying when a TOO MANY REQUESTS STATUS is received (status code: 429) */ class MaxRetryHttpRequestStrategy extends ExponentialBackoffRequestStrategy { /** * @param maxRetryCount - The maximum number of retries to attempt, default is 5, set to 0 for indefinite retries */ constructor(maxRetryCount = 5) { super({ delayFirstRequest: false, maxRetryCount, baseDelay: 0, factor: 1, maxDelay: 0, }); } async request(request) { return await super.request(request); } } class TimeoutError extends HttpError { constructor(message, options) { super(message, options); } } /** This strategy is used to set a timeout on a request */ class TimeoutHttpRequestStrategy extends DefaultHttpRequestStrategy { timeout; /** * @param timeout - The max time a request can take before aborting */ constructor(timeout = 10000) { super(); this.timeout = timeout; } request(request) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new TimeoutError('Request timed out')); }, this.timeout); super .request(request) .then((response) => resolve(response)) .catch((error) => reject(error)) .finally(() => clearTimeout(timeout)); }); } } const ERROR_URL = 'Url must be a string'; const ABORT_MESSAGE = 'Aborted by token'; /** Typed wrapper around axios that standardizes making HTTP calls and handling responses */ class HttpClient { httpClientAdaptor; logger; httpRequestStrategy; baseUrl; constructor(httpClientAdaptor = new FetchClientAdaptor(), options = {}) { this.httpClientAdaptor = httpClientAdaptor; const { httpRequestStrategy, logger, baseUrl = '' } = options; this.httpRequestStrategy = httpRequestStrategy ?? new DefaultHttpRequestStrategy(); this.logger = logger; this.baseUrl = baseUrl; } /** * Sets the logger for the instance * @param {Logger|undefined} logger */ setLogger(logger) { this.logger = logger; } /** HTTP GET request */ get(url, config = {}, cancelToken) { const method = 'get'; return this.dataRequest(url, method, config, cancelToken); } /** HTTP POST request */ post(url, config = {}, cancelToken) { const method = 'post'; return this.dataRequest(url, method, config, cancelToken); } /** HTTP PUT request */ put(url, config = {}, cancelToken) { const method = 'put'; return this.dataRequest(url, method, config, cancelToken); } /** HTTP DELETE request */ delete(url, config = {}, cancelToken) { const method = 'delete'; return this.dataRequest(url, method, config, cancelToken); } /** HTTP PATCH request */ patch(url, config = {}, cancelToken) { const method = 'patch'; return this.dataRequest(url, method, config, cancelToken); } /** * HTTP request that returns the body of the HTTP response * * If a cancel token is passed in it will be aborted on request error. * * @returns {Promise<T>} body of the HTTP response */ async dataRequest(url, method, config = {}, cancelToken) { const response = await this.request(url, method, config, cancelToken); return response.data; } /** * HTTP request * * If a cancel token is passed in it will be aborted on request error. * * @returns {Promise<HttpResponse<T>>} HttpResponse */ async request(url, method, config = {}, cancelToken) { if (cancelToken?.signal.aborted) { throw new AbortError(ABORT_MESSAGE); } try { return await this.doRequest(url, method, config, cancelToken); } catch (e) { let message = `The ${method} request to ${url} failed`; if (e && typeof e === 'object' && 'status' in e) { message += ` with status ${e.status}`; } cancelToken?.abort(message); throw e; } } async doRequest(url, method, config = {}, cancelToken) { if (typeof url !== 'string') throw new Error(ERROR_URL); const { headers, data, params, responseEncoding, responseType, httpRequestStrategy, noGlobal, } = config; const strategyToUse = httpRequestStrategy ?? this.httpRequestStrategy; const requestConfig = { url: this.baseUrl + url, method, headers, data, params, responseEncoding, responseType, cancelToken, noGlobal, }; try { const request = this.httpClientAdaptor.buildRequest(requestConfig); this.logger?.debug(`HTTP - method: ${method}; url: ${url}`); const response = await strategyToUse.request(request); this.logger?.debug(`HTTP ${response.status} - method: ${method}; url: ${url}`); return response; } catch (e) { this.logger?.error(`HTTP error - method: ${method}; url: ${url}`, e); throw e; } } } function isHttpError(error) { return 'isHttpClientError' in error; } export { ABORT_MESSAGE, AbortError, DefaultHttpRequestStrategy, ERROR_URL, ExponentialBackoffRequestStrategy, HttpClient, HttpError, MaxRetryHttpRequestStrategy, TimeoutHttpRequestStrategy, getIsSuccessfulHttpStatus, isHttpError };