@seriouslag/httpclient
Version:
Typed wrapper HttpClient for axios
315 lines (301 loc) • 10.6 kB
JavaScript
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 };