Skip to content

Commit

Permalink
feat(core): make http client configurable and add retry support
Browse files Browse the repository at this point in the history
Closes #31
  • Loading branch information
Wajahat Iqbal authored Jan 3, 2022
1 parent a3d0023 commit 07ff535
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 2 deletions.
20 changes: 19 additions & 1 deletion packages/core/src/http/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,19 @@ export class HttpClient {
constructor({
clientConfigOverrides,
timeout = DEFAULT_TIMEOUT,
}: { clientConfigOverrides?: AxiosRequestConfig; timeout?: number } = {}) {
httpAgent,
httpsAgent,
}: {
clientConfigOverrides?: AxiosRequestConfig;
timeout?: number;
httpAgent?: any;
httpsAgent?: any;
} = {}) {
this._timeout = timeout;
this._axiosInstance = axios.create({
...DEFAULT_AXIOS_CONFIG_OVERRIDES,
...clientConfigOverrides,
...{ httpAgent, httpsAgent },
});
}

Expand Down Expand Up @@ -179,3 +187,13 @@ export class HttpClient {
return new AbortError('The HTTP call was aborted.');
}
}

/** Stable configurable http client options. */
export interface HttpClientOptions {
/** Timeout in milliseconds. */
timeout: number;
/** Custom http agent to be used when performing http requests. */
httpAgent?: any;
/** Custom https agent to be used when performing https requests. */
httpsAgent?: any;
}
45 changes: 44 additions & 1 deletion packages/core/src/http/requestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
urlEncodeObject,
} from './queryString';
import { prepareArgs } from './validate';
import { RetryConfiguration, getRetryWaitTime } from './retryConfiguration';

export type RequestBuilderFactory<BaseUrlParamType, AuthParams> = (
httpMethod: HttpMethod,
Expand Down Expand Up @@ -204,6 +205,7 @@ export class DefaultRequestBuilder<BaseUrlParamType, AuthParams>
protected _authenticationProvider: AuthenticatorInterface<AuthParams>,
protected _httpMethod: HttpMethod,
protected _xmlSerializer: XmlSerializerInterface,
protected _retryConfig: RetryConfiguration,
protected _path?: string
) {
this._headers = {};
Expand All @@ -212,6 +214,7 @@ export class DefaultRequestBuilder<BaseUrlParamType, AuthParams>
this._validateResponse = true;
this._addResponseValidator();
this._addAuthentication();
this._addRetryInterceptor();
this.prepareArgs = prepareArgs.bind(this);
}
public authenticate(params: AuthParams): void {
Expand Down Expand Up @@ -536,14 +539,53 @@ export class DefaultRequestBuilder<BaseUrlParamType, AuthParams>
return handler(...args);
});
}
private _addRetryInterceptor() {
this.intercept(async (request, options, next) => {
let context: HttpContext | undefined;
let allowedWaitTime = this._retryConfig.maximumRetryWaitTime;
let retryCount = 0;
let waitTime = 0;
let timeoutError: Error | undefined;
do {
timeoutError = undefined;
if (retryCount > 0) {
await new Promise((res) => setTimeout(res, waitTime * 1000));
allowedWaitTime -= waitTime;
}
try {
context = await next(request, options);
} catch (error) {
timeoutError = error;
}
waitTime = getRetryWaitTime(
this._retryConfig,
this._httpMethod,
allowedWaitTime,
retryCount,
context?.response.statusCode,
context?.response?.headers,
timeoutError
);
retryCount++;
} while (waitTime > 0);
if (timeoutError) {
throw timeoutError;
}
if (typeof context?.response === 'undefined') {
throw new Error('Response is undefined.');
}
return { request, response: context.response };
});
}
}

export function createRequestBuilderFactory<BaseUrlParamType, AuthParams>(
httpClient: HttpClientInterface,
baseUrlProvider: (arg?: BaseUrlParamType) => string,
apiErrorFactory: ApiErrorConstructor,
authenticationProvider: AuthenticatorInterface<AuthParams>,
xmlSerializer: XmlSerializerInterface
xmlSerializer: XmlSerializerInterface,
retryConfig: RetryConfiguration
): RequestBuilderFactory<BaseUrlParamType, AuthParams> {
return (httpMethod, path?) => {
return new DefaultRequestBuilder(
Expand All @@ -553,6 +595,7 @@ export function createRequestBuilderFactory<BaseUrlParamType, AuthParams>(
authenticationProvider,
httpMethod,
xmlSerializer,
retryConfig,
path
);
};
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/http/retryConfiguration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { getHeader } from './httpHeaders';
import { HttpMethod } from './httpRequest';

/**
* An interface for all configuration parameters needed for retrying in case of transient failures.
*/
export interface RetryConfiguration {
/** Maximum number of retries. */
maxNumberOfRetries: number;
/** Whether to retry on request timeout. */
retryOnTimeout: boolean;
/**
* Interval before next retry.
* Used in calculation of wait time for next request in case of failure.
*/
retryInterval: number;
/** Overall wait time for the requests getting retried. */
maximumRetryWaitTime: number;
/** Used in calculation of wait time for next request in case of failure. */
backoffFactor: number;
/** Http status codes to retry against. */
httpStatusCodesToRetry: number[];
/** Http status codes to retry against. */
httpMethodsToRetry: HttpMethod[];
}

/**
* Returns wait time for the request
* @param retryConfig Configuration for retry
* @param method HttpMethod of the request
* @param allowedWaitTime Remaining allowed wait time
* @param retryCount Retry attempt number
* @param httpCode Status code received
* @param headers Response headers
* @param timeoutError Error from the server
* @returns Wait time before the retry
*/
export function getRetryWaitTime(
retryConfig: RetryConfiguration,
method: HttpMethod,
allowedWaitTime: number,
retryCount: number,
httpCode?: number,
headers?: Record<string, string>,
timeoutError?: Error
): number {
let retryWaitTime = 0.0;
let retry = false;
let retryAfter = 0;
if (
retryConfig.httpMethodsToRetry.includes(method) &&
retryCount < retryConfig.maxNumberOfRetries
) {
if (timeoutError) {
retry = retryConfig.retryOnTimeout;
} else if (
typeof headers !== 'undefined' &&
typeof httpCode !== 'undefined'
) {
retryAfter = getRetryAfterSeconds(getHeader(headers, 'retry-after'));
retry =
retryAfter > 0 || retryConfig.httpStatusCodesToRetry.includes(httpCode);
}

if (retry) {
const noise = +(Math.random() / 100).toFixed(3);
let waitTime =
retryConfig.retryInterval *
Math.pow(retryConfig.backoffFactor, retryCount) +
noise;
waitTime = Math.max(waitTime, retryAfter);
if (waitTime <= allowedWaitTime) {
retryWaitTime = waitTime;
}
}
}
return retryWaitTime;
}

function getRetryAfterSeconds(retryAfter: string | null): number {
if (retryAfter == null) {
return 0;
}
if (isNaN(+retryAfter)) {
const timeDifference = (new Date(retryAfter).getTime() - Date.now()) / 1000;
return isNaN(timeDifference) ? 0 : timeDifference;
}
return +retryAfter;
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './http/httpInterceptor';
export * from './http/httpRequest';
export * from './http/requestBuilder';
export * from './http/pathTemplate';
export { RetryConfiguration } from './http/retryConfiguration';

0 comments on commit 07ff535

Please sign in to comment.