Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### Added
- Allow configuring the HTTP retry strategy via `restClientConfig.retry` and tune the default policy.
### Security
- Updated versions of vulnerable packages (axios).

## [5.4.1] - 2025-07-24
### Added
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ When creating a client instance, you need to specify the following options:
| headers | Optional | {} | The object with custom headers for internal http client. |
| debug | Optional | false | This flag allows seeing the logs of the client. Useful for debugging. |
| isLaunchMergeRequired | Optional | false | Allows client to merge launches into one at the end of the run via saving their UUIDs to the temp files at filesystem. At the end of the run launches can be merged using `mergeLaunches` method. Temp file format: `rplaunch-${launch_uuid}.tmp`. |
| restClientConfig | Optional | Not set | `axios` like http client [config](https://github.com/axios/axios#request-config). May contain `agent` property for configure [http(s)](https://nodejs.org/api/https.html#https_https_request_url_options_callback) client, and other client options eg. `timeout`. For debugging and displaying logs you can set `debug: true`. |
| restClientConfig | Optional | Not set | `axios` like http client [config](https://github.com/axios/axios#request-config). May contain `agent` property for configure [http(s)](https://nodejs.org/api/https.html#https_https_request_url_options_callback) client, and other client options eg. `timeout`. For debugging and displaying logs you can set `debug: true`. Use the `retry` property (number or [`axios-retry`](https://github.com/softonic/axios-retry#options) config) to customise automatic retries. |
| launchUuidPrint | Optional | false | Whether to print the current launch UUID. |
| launchUuidPrintOutput | Optional | 'STDOUT' | Launch UUID printing output. Possible values: 'STDOUT', 'STDERR', 'FILE', 'ENVIRONMENT'. Works only if `launchUuidPrint` set to `true`. File format: `rp-launch-uuid-${launch_uuid}.tmp`. Env variable: `RP_LAUNCH_UUID`. |
| token | Deprecated | Not set | Use `apiKey` instead. |
Expand All @@ -88,6 +88,26 @@ There is a timeout on axios requests. If for instance the server your making a r

You can simply change this timeout by adding a `timeout` property to `restClientConfig` with your desired numeric value (in _ms_) or *0* to disable it.

### Retry configuration

The client retries failed HTTP calls up to 6 times with an exponential backoff (starting at 200 ms and capping at 5 s) and resets the axios timeout before each retry. Provide a `retry` option in `restClientConfig` to change that behaviour. The value can be either a number (overriding just the retry count) or a full [`axios-retry` configuration object](https://github.com/softonic/axios-retry#options):

```javascript
const axiosRetry = require('axios-retry').default;

const client = new RPClient({
// ... other options
restClientConfig: {
retry: {
retries: 5,
retryDelay: axiosRetry.exponentialDelay,
},
},
});
```

Setting `retry: 0` disables automatic retries.

### checkConnect

`checkConnect` - asynchronous method for verifying the correctness of the client connection
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.4.1
5.4.2-SNAPSHOT
31 changes: 23 additions & 8 deletions __tests__/client-id.spec.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
const fs = require('fs');
const util = require('util');
const os = require('os');
const path = require('path');
const { v4: uuidv4 } = require('uuid');

const testHomeDir = path.join(__dirname, '__tmp__', 'rp-home');
process.env.RP_CLIENT_JS_HOME = testHomeDir;
const { getClientId } = require('../statistics/client-id');

const uuidv4Validation = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
const clientIdFile = path.join(os.homedir(), '.rp', 'rp.properties');
const clientIdFile = path.join(testHomeDir, '.rp', 'rp.properties');

const unlink = util.promisify(fs.unlink);
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const removeTestHomeDir = () => fs.promises.rm(testHomeDir, { recursive: true, force: true });
const unlinkFile = async (filePath) => {
try {
await unlink(filePath);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};

describe('Client ID test suite', () => {
beforeAll(removeTestHomeDir);
afterAll(removeTestHomeDir);

it('getClientId should return the same client ID for two calls', async () => {
const clientId1 = await getClientId();
const clientId2 = await getClientId();
Expand All @@ -22,7 +37,7 @@ describe('Client ID test suite', () => {

it('getClientId should return different client IDs if store file removed', async () => {
const clientId1 = await getClientId();
await unlink(clientIdFile);
await unlinkFile(clientIdFile);
const clientId2 = await getClientId();
expect(clientId2).not.toEqual(clientId1);
});
Expand All @@ -33,14 +48,14 @@ describe('Client ID test suite', () => {
});

it('getClientId should save client ID to ~/.rp/rp.properties', async () => {
await unlink(clientIdFile);
await unlinkFile(clientIdFile);
const clientId = await getClientId();
const content = await readFile(clientIdFile, 'utf-8');
expect(content).toMatch(new RegExp(`^client\\.id\\s*=\\s*${clientId}\\s*(?:$|\n)`));
});

it('getClientId should read client ID from ~/.rp/rp.properties', async () => {
await unlink(clientIdFile);
await unlinkFile(clientIdFile);
const clientId = uuidv4(undefined, undefined, 0);
await writeFile(clientIdFile, `client.id=${clientId}\n`, 'utf-8');
expect(await getClientId()).toEqual(clientId);
Expand All @@ -50,7 +65,7 @@ describe('Client ID test suite', () => {
'getClientId should read client ID from ~/.rp/rp.properties if it is not empty and client ID is the ' +
'first line',
async () => {
await unlink(clientIdFile);
await unlinkFile(clientIdFile);
const clientId = uuidv4(undefined, undefined, 0);
await writeFile(clientIdFile, `client.id=${clientId}\ntest.property=555\n`, 'utf-8');
expect(await getClientId()).toEqual(clientId);
Expand All @@ -61,15 +76,15 @@ describe('Client ID test suite', () => {
'getClientId should read client ID from ~/.rp/rp.properties if it is not empty and client ID is not the ' +
'first line',
async () => {
await unlink(clientIdFile);
await unlinkFile(clientIdFile);
const clientId = uuidv4(undefined, undefined, 0);
await writeFile(clientIdFile, `test.property=555\nclient.id=${clientId}\n`, 'utf-8');
expect(await getClientId()).toEqual(clientId);
},
);

it('getClientId should write client ID to ~/.rp/rp.properties if it is not empty', async () => {
await unlink(clientIdFile);
await unlinkFile(clientIdFile);
await writeFile(clientIdFile, `test.property=555`, 'utf-8');
const clientId = await getClientId();
const content = await readFile(clientIdFile, 'utf-8');
Expand Down
60 changes: 60 additions & 0 deletions __tests__/rest.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ describe('RestClient', () => {
},
};
const noOptions = {};
const getRetryAttempts = (client) => client.getRetryConfig().retries + 1;
const restClient = new RestClient(options);
const retryAttempts = getRetryAttempts(restClient);

const unathorizedError = {
error: 'unauthorized',
Expand Down Expand Up @@ -53,6 +55,64 @@ describe('RestClient', () => {
});
});

describe('retry configuration', () => {
it('uses a production-ready retry policy by default', () => {
const retryConfig = restClient.getRetryConfig();

expect(retryConfig.retries).toBe(6);
expect(retryAttempts).toBe(retryConfig.retries + 1);
expect(retryConfig.shouldResetTimeout).toBe(true);
expect(retryConfig.retryDelay(1)).toBe(200);
expect(retryConfig.retryDelay(4)).toBe(1600);
expect(retryConfig.retryDelay(10)).toBe(5000);
});

it('uses custom retry attempts when a numeric value is provided', (done) => {
const customRetries = 2;
const client = new RestClient({
...options,
restClientConfig: {
...options.restClientConfig,
retry: customRetries,
},
});
expect(getRetryAttempts(client)).toBe(customRetries + 1);

const scope = nock(options.baseURL)
.get('/users/custom-retry-number')
.replyWithError(netErrConnectionResetError);

client.retrieve('users/custom-retry-number', noOptions).catch((error) => {
expect(error instanceof Error).toBeTruthy();
expect(error.message).toMatch(netErrConnectionResetError.message);
expect(scope.isDone()).toBeTruthy();

done();
});
});

it('merges retry configuration object from settings', () => {
const customDelay = () => 250;
const client = new RestClient({
...options,
restClientConfig: {
...options.restClientConfig,
retry: {
retries: 4,
retryDelay: customDelay,
shouldResetTimeout: true,
},
},
});

const retryConfig = client.getRetryConfig();

expect(retryConfig.retries).toBe(4);
expect(retryConfig.retryDelay).toBe(customDelay);
expect(retryConfig.shouldResetTimeout).toBe(true);
});
});

describe('buildPath', () => {
it('compose path basing on base', () => {
expect(restClient.buildPath('users')).toBe(`${options.baseURL}/users`);
Expand Down
39 changes: 33 additions & 6 deletions lib/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ const https = require('https');
const logger = require('./logger');

const DEFAULT_MAX_CONNECTION_TIME_MS = 30000;

axiosRetry(axios, {
retryDelay: () => 100,
retries: 10,
const DEFAULT_RETRY_ATTEMPTS = 6;
const RETRY_BASE_DELAY_MS = 200;
const RETRY_MAX_DELAY_MS = 5000;
const DEFAULT_RETRY_CONFIG = {
retryDelay: (retryCount = 1) =>
Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.max(retryCount - 1, 0), RETRY_MAX_DELAY_MS),
retries: DEFAULT_RETRY_ATTEMPTS,
retryCondition: axiosRetry.isRetryableError,
});
shouldResetTimeout: true,
};
const SKIPPED_REST_CONFIG_KEYS = ['agent', 'retry'];

class RestClient {
constructor(options) {
Expand All @@ -24,6 +29,8 @@ class RestClient {
...this.getRestConfig(this.restClientConfig),
});

axiosRetry(this.axiosInstance, this.getRetryConfig());

if (this.restClientConfig?.debug) {
logger.addLogger(this.axiosInstance);
}
Expand Down Expand Up @@ -69,7 +76,7 @@ method: ${method}`,
if (!this.restClientConfig) return {};

const config = Object.keys(this.restClientConfig).reduce((acc, key) => {
if (key !== 'agent') {
if (!SKIPPED_REST_CONFIG_KEYS.includes(key)) {
acc[key] = this.restClientConfig[key];
}
return acc;
Expand All @@ -87,6 +94,26 @@ method: ${method}`,
return config;
}

getRetryConfig() {
const retryOption = this.restClientConfig?.retry;

if (typeof retryOption === 'number') {
return {
...DEFAULT_RETRY_CONFIG,
retries: retryOption,
};
}

if (retryOption && typeof retryOption === 'object') {
return {
...DEFAULT_RETRY_CONFIG,
...retryOption,
};
}

return { ...DEFAULT_RETRY_CONFIG };
}

create(path, data, options = {}) {
return this.request('POST', this.buildPath(path), data, {
...options,
Expand Down
22 changes: 12 additions & 10 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"node": ">=14.x"
},
"dependencies": {
"axios": "^1.8.4",
"axios-retry": "^4.1.0",
"axios": "^1.12.2",
"axios-retry": "^4.5.0",
"glob": "^8.1.0",
"ini": "^2.0.0",
"uniqid": "^5.4.0",
Expand Down
3 changes: 2 additions & 1 deletion statistics/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const PJSON_NAME = pjson.name;
const CLIENT_ID_KEY = 'client.id';
const RP_FOLDER = '.rp';
const RP_PROPERTIES_FILE = 'rp.properties';
const RP_FOLDER_PATH = path.join(os.homedir(), RP_FOLDER);
const HOME_DIRECTORY = process.env.RP_CLIENT_JS_HOME || os.homedir();
const RP_FOLDER_PATH = path.join(HOME_DIRECTORY, RP_FOLDER);
const RP_PROPERTIES_FILE_PATH = path.join(RP_FOLDER_PATH, RP_PROPERTIES_FILE);
const CLIENT_INFO = Buffer.from(
'Ry1XUDU3UlNHOFhMOmVFazhPMGJ0UXZ5MmI2VXVRT19TOFE=',
Expand Down
Loading