Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add enableRetries and disableRetries #156

Merged
merged 13 commits into from
Aug 27, 2021
Merged
12 changes: 12 additions & 0 deletions auth/utils/read-external-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ function filterPropertiesByServiceName(envObj: any, serviceName: string): any {
credentials.enableGzip = credentials.enableGzip === 'true';
}

if (typeof credentials.enableRetries === 'string') {
credentials.enableRetries = credentials.enableRetries === 'true';
}

if (typeof credentials.maxRetries === 'string') {
credentials.maxRetries = parseInt(credentials.maxRetries, 10);
}

if (typeof credentials.retryInterval === 'string') {
credentials.retryInterval = parseInt(credentials.retryInterval, 10);
}

return credentials;
}

Expand Down
42 changes: 35 additions & 7 deletions lib/base-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { OutgoingHttpHeaders } from 'http';
import { AuthenticatorInterface, checkCredentials, readExternalSources } from '../auth';
import { stripTrailingSlash } from './helper';
import logger from './logger';
import { RequestWrapper } from './request-wrapper';
import { RequestWrapper, RetryOptions } from './request-wrapper';

/**
* Configuration values for a service.
Expand Down Expand Up @@ -47,7 +47,10 @@ export interface UserOptions {
*/
export interface BaseServiceOptions extends UserOptions {
/** Querystring to be sent with every request. If not a string will be stringified. */
qs: any;
qs?: any;
enableRetries?: boolean;
maxRetries?: number;
retryInterval?: number;
}

/**
Expand All @@ -65,7 +68,7 @@ export class BaseService {

private authenticator: AuthenticatorInterface;

private requestWrapperInstance;
private requestWrapperInstance: RequestWrapper;

/**
* Configuration values for a service.
Expand Down Expand Up @@ -156,7 +159,7 @@ export class BaseService {
* @param {boolean} setting Will turn it on if 'true', off if 'false'.
*/
public setEnableGzipCompression(setting: boolean): void {
this.requestWrapperInstance.compressRequestData = setting;
this.requestWrapperInstance.setCompressRequestData(setting);

// persist setting so that baseOptions accurately reflects the state of the flag
this.baseOptions.enableGzipCompression = setting;
Expand All @@ -170,6 +173,22 @@ export class BaseService {
return this.requestWrapperInstance.getHttpClient();
}

/**
* Enable retries for unfulfilled requests.
*
* @param {RetryOptions} retryOptions configuration for retries
*/
public enableRetries(retryOptions?: RetryOptions): void {
this.requestWrapperInstance.enableRetries(retryOptions);
}

/**
* Disables retries.
*/
public disableRetries(): void {
this.requestWrapperInstance.disableRetries();
}

/**
* Configure the service using external configuration
*
Expand Down Expand Up @@ -223,8 +242,8 @@ export class BaseService {
}

// eslint-disable-next-line class-methods-use-this
private readOptionsFromExternalConfig(serviceName: string) {
const results = {} as any;
private readOptionsFromExternalConfig(serviceName: string): BaseServiceOptions {
const results: BaseServiceOptions = {};
const properties = readExternalSources(serviceName);

if (properties !== null) {
Expand All @@ -233,7 +252,7 @@ export class BaseService {
// - disableSsl
// - enableGzip

const { url, disableSsl, enableGzip } = properties;
const { url, disableSsl, enableGzip, enableRetries, maxRetries, retryInterval } = properties;

if (url) {
results.serviceUrl = stripTrailingSlash(url);
Expand All @@ -244,6 +263,15 @@ export class BaseService {
if (enableGzip === true) {
results.enableGzipCompression = enableGzip;
}
if (enableRetries !== undefined) {
results.enableRetries = enableRetries;
}
if (maxRetries !== undefined) {
results.maxRetries = maxRetries;
}
if (retryInterval !== undefined) {
results.retryInterval = retryInterval;
}
}

return results;
Expand Down
88 changes: 85 additions & 3 deletions lib/request-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
* limitations under the License.
*/

import axios, { AxiosRequestConfig } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import * as rax from 'retry-axios';

import axiosCookieJarSupport from 'axios-cookiejar-support';
import extend = require('extend');
Expand All @@ -36,11 +37,30 @@ import {
import logger from './logger';
import { streamToPromise } from './stream-to-promise';

/**
* Retry configuration options.
*/
export interface RetryOptions {
/**
* Maximum retries to attempt.
*/
maxRetries?: number;

/**
* Ceiling for the retry delay (in seconds) - delay will not exceed this value.
*/
maxRetryInterval?: number;
}

export class RequestWrapper {
private axiosInstance;
private axiosInstance: AxiosInstance;

private compressRequestData: boolean;

private retryInterceptorId: number;

private raxConfig: rax.RetryConfig;

constructor(axiosOptions?) {
axiosOptions = axiosOptions || {};
this.compressRequestData = Boolean(axiosOptions.enableGzipCompression);
Expand Down Expand Up @@ -95,6 +115,18 @@ export class RequestWrapper {
this.axiosInstance.defaults.jar = axiosOptions.jar;
}

// get retry config properties and conditionally enable retries
if (axiosOptions.enableRetries) {
const retryOptions: RetryOptions = {};
if (axiosOptions.maxRetries !== undefined) {
retryOptions.maxRetries = axiosOptions.maxRetries;
}
if (axiosOptions.retryInterval !== undefined) {
retryOptions.maxRetryInterval = axiosOptions.retryInterval;
}
this.enableRetries(retryOptions);
}

// set debug interceptors
if (process.env.NODE_DEBUG === 'axios' || process.env.DEBUG) {
this.axiosInstance.interceptors.request.use(
Expand Down Expand Up @@ -145,6 +177,10 @@ export class RequestWrapper {
}
}

public setCompressRequestData(setting: boolean) {
this.compressRequestData = setting;
}

/**
* Creates the request.
* 1. Merge default options with user provided options
Expand Down Expand Up @@ -240,6 +276,7 @@ export class RequestWrapper {
headers,
params: qs,
data,
raxConfig: this.raxConfig,
padamstx marked this conversation as resolved.
Show resolved Hide resolved
responseType: options.responseType || 'json',
paramsSerializer: (params) => querystring.stringify(params),
};
Expand All @@ -257,7 +294,8 @@ export class RequestWrapper {
delete res.request;

// the other sdks use the interface `result` for the body
res.result = res.data;
// eslint-disable-next-line @typescript-eslint/dot-notation
dpopp07 marked this conversation as resolved.
Show resolved Hide resolved
res['result'] = res.data;
delete res.data;

// return another promise that resolves with 'res' to be handled in generated code
Expand Down Expand Up @@ -346,6 +384,50 @@ export class RequestWrapper {
return this.axiosInstance;
}

private static getRaxConfig(
axiosInstance: AxiosInstance,
retryOptions?: RetryOptions
): rax.RetryConfig {
const config: rax.RetryConfig = {
retry: 4, // 4 retries by default
retryDelay: 1000, // 1000 ms (1 sec) initial delay
instance: axiosInstance,
backoffType: 'exponential',
checkRetryAfter: true, // use Retry-After header first
maxRetryDelay: 30 * 1000, // default to 30 sec max delay
};

if (retryOptions) {
if (typeof retryOptions.maxRetries === 'number') {
config.retry = retryOptions.maxRetries;
}
if (typeof retryOptions.maxRetryInterval === 'number') {
// convert seconds to ms for retry-axios
config.maxRetryDelay = retryOptions.maxRetryInterval * 1000;
}
}

return config;
}

public enableRetries(retryOptions?: RetryOptions): void {
// avoid attaching the same interceptor multiple times
// to protect against user error and ensure disableRetries() always disables retries
if (typeof this.retryInterceptorId === 'number') {
this.disableRetries();
}
this.raxConfig = RequestWrapper.getRaxConfig(this.axiosInstance, retryOptions);
this.retryInterceptorId = rax.attach(this.axiosInstance);
}

public disableRetries(): void {
if (typeof this.retryInterceptorId === 'number') {
rax.detach(this.retryInterceptorId, this.axiosInstance);
padamstx marked this conversation as resolved.
Show resolved Hide resolved
delete this.retryInterceptorId;
delete this.raxConfig;
}
}

private async gzipRequestBody(data: any, headers: OutgoingHttpHeaders): Promise<Buffer | any> {
// skip compression if user has set the encoding header to gzip
const contentSetToGzip =
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"eslint-plugin-node": "^9.0.0",
"eslint-plugin-prettier": "^3.0.1",
"jest": "^26.6.3",
"nock": "^13.1.2",
"object.assign": "~4.1.0",
"prettier": "^2.3.0",
"semantic-release": "17.4.2",
Expand Down Expand Up @@ -78,6 +79,7 @@
"mime-types": "~2.1.18",
"object.omit": "~3.0.0",
"object.pick": "~1.3.0",
"retry-axios": "^2.6.0",
"semver": "^6.2.0",
"tough-cookie": "^4.0.0"
},
Expand Down
7 changes: 6 additions & 1 deletion test/resources/ibm-credentials.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ TEST_SERVICE_URL=service.com/api
TEST_SERVICE_DISABLE_SSL=true
TEST_SERVICE_SCOPE=A B C D

# retry properties
TEST_SERVICE_ENABLE_RETRIES=true
TEST_SERVICE_MAX_RETRIES=1234
TEST_SERVICE_RETRY_INTERVAL=5678

# Service1 auth properties configured with IAM and a token containing '='
SERVICE_1_AUTH_TYPE=iam
SERVICE_1_APIKEY=V4HXmoUtMjohnsnow=KotN
Expand All @@ -33,4 +38,4 @@ SERVICE_2_SCOPE=A B C D
# Service3 configured with basic auth
SERVICE_3_AUTHTYPE=basic
SERVICE_3_USERNAME=user1
SERVICE_3_PASSWORD=password1
SERVICE_3_PASSWORD=password1
Loading