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

[sitecore-jss] Retry policy to handle transient network errors #1731

Merged
merged 12 commits into from
Feb 9, 2024
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ Our versioning strategy is as follows:
* `import { editingDataService } from '@sitecore-jss/sitecore-jss-nextjs/editing';`
* `import { EditingRenderMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/editing';`

## 20.3.0

### 🎉 New Features & Improvements

* `[sitecore-jss]` Retry policy to handle transient network errors. Users can pass `retryStrategy` to configure custom retry config to the services. They can customize the error codes and the number of retries. It consist of two functions shouldRetry and getDelay. ([#1731](https://github.com/Sitecore/jss/pull/1731))

## 20.2.3

### 🐛 Bug Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,17 @@ export class DictionaryServiceFactory {
GraphQL Dictionary and Layout Services can handle rate limit errors from server and attempt a retry on requests.
For this, specify the number of retries the GraphQL client will attempt.
It will only try the request once by default.
retries: 'number'
retries: 'number'
Additionally, you have the flexibility to customize the retry strategy by passing your customized RetryStrategy
object using the DefaultRetryStrategy class. The DefaultRetryStrategy class, which can be imported from the @sitecore-jss-nextjs
package, provides two essential methods: 'shouldRetry' which returns a boolean indicating whether a retry should
be attempted, and 'getDelay' which calculates the delay (in milliseconds) before the subsequent retry based on
the encountered error and the current attempt.
Example:
retryStrategy: new DefaultRetryStrategy({
statusCodes: 'number[]',
factor: 'number' (The exponential factor to calculate backoff-time),
}),
*/
retries:
(process.env.GRAPH_QL_SERVICE_RETRIES &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,19 @@ export class LayoutServiceFactory {
/*
GraphQL endpoint may reach its rate limit with the amount of Layout and Dictionary requests it receives and throw a rate limit error.
GraphQL Dictionary and Layout Services can handle rate limit errors from server and attempt a retry on requests.
For this, specify the number of retries the GraphQL client will attempt.
For this, specify the number of retries the GraphQL client will attempt.
It will only try the request once by default.
retries: 'number'
retries: 'number'
Additionally, you have the flexibility to customize the retry strategy by passing your customized RetryStrategy
object using the DefaultRetryStrategy class. The DefaultRetryStrategy class, which can be imported from the @sitecore-jss-nextjs
package, provides two essential methods: 'shouldRetry' which returns a boolean indicating whether a retry should
be attempted, and 'getDelay' which calculates the delay (in milliseconds) before the subsequent retry based on
the encountered error and the current attempt.
Example:
retryStrategy: new DefaultRetryStrategy({
statusCodes: 'number[]',
factor: 'number' (The exponential factor to calculate backoff-time),
}),
*/
retries:
(process.env.GRAPH_QL_SERVICE_RETRIES &&
Expand Down
1 change: 1 addition & 0 deletions packages/sitecore-jss-angular/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export {
ComponentFields,
ComponentParams,
} from '@sitecore-jss/sitecore-jss/layout';
export { RetryStrategy, DefaultRetryStrategy } from '@sitecore-jss/sitecore-jss/graphql';
export { constants, HttpDataFetcher, HttpResponse, enableDebug } from '@sitecore-jss/sitecore-jss';
export {
isServer,
Expand Down
2 changes: 2 additions & 0 deletions packages/sitecore-jss-nextjs/src/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export {
RetryStrategy,
DefaultRetryStrategy,
GraphQLRequestClient,
GraphQLRequestClientFactory,
GraphQLRequestClientFactoryConfig,
Expand Down
1 change: 1 addition & 0 deletions packages/sitecore-jss-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export {
GraphQLDictionaryService,
RestDictionaryService,
} from '@sitecore-jss/sitecore-jss/i18n';
export { RetryStrategy, DefaultRetryStrategy } from '@sitecore-jss/sitecore-jss/graphql';
export { mediaApi } from '@sitecore-jss/sitecore-jss/media';
export { getFEAASLibraryStylesheetLinks } from '@sitecore-jss/sitecore-jss/feaas';
export { ComponentFactory } from './components/sharedTypes';
Expand Down
1 change: 1 addition & 0 deletions packages/sitecore-jss-vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
GraphQLDictionaryService,
RestDictionaryService,
} from '@sitecore-jss/sitecore-jss/i18n';
export { RetryStrategy, DefaultRetryStrategy } from '@sitecore-jss/sitecore-jss/graphql';
export { mediaApi } from '@sitecore-jss/sitecore-jss/media';
export { EditFrame } from './components/EditFrame';
export { Placeholder } from './components/Placeholder';
Expand Down
2 changes: 2 additions & 0 deletions packages/sitecore-jss/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@types/memory-cache": "^0.2.1",
"@types/mocha": "^9.0.0",
"@types/node": "^16.11.6",
"@types/sinon": "^17.0.3",
"@types/url-parse": "1.4.8",
"chai": "^4.2.0",
"chai-spies": "^1.0.0",
Expand All @@ -47,6 +48,7 @@
"mocha": "^10.2.0",
"nock": "^13.0.5",
"nyc": "^15.1.0",
"sinon": "^17.0.1",
"ts-node": "^8.4.1",
"tslib": "^1.10.0",
"typescript": "~4.3.5"
Expand Down
184 changes: 172 additions & 12 deletions packages/sitecore-jss/src/graphql-request-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable dot-notation */
import { expect, use, spy } from 'chai';
import sinon from 'sinon';
import spies from 'chai-spies';
import nock from 'nock';
import { GraphQLRequestClient } from './graphql-request-client';
Expand Down Expand Up @@ -118,7 +119,6 @@ describe('GraphQLRequestClient', () => {
);
}
});

it('should throw error when request is aborted with default timeout value', async () => {
nock('http://jssnextweb')
.post('/graphql')
Expand All @@ -136,15 +136,18 @@ describe('GraphQLRequestClient', () => {
});

it('should use retry and throw error when retries specified', async function() {
this.timeout(6000);
this.timeout(8000);
nock('http://jssnextweb')
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429);
const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 2 });

const graphQLClient = new GraphQLRequestClient(endpoint, {
retries: 2,
});
spy.on(graphQLClient['client'], 'request');
await graphQLClient.request('test').catch((error) => {
expect(error).to.not.be.undefined;
Expand All @@ -154,7 +157,7 @@ describe('GraphQLRequestClient', () => {
});

it('should use retry and resolve if one of the requests resolves', async function() {
this.timeout(6000);
this.timeout(8000);
nock('http://jssnextweb')
.post('/graphql')
.reply(429)
Expand All @@ -166,7 +169,7 @@ describe('GraphQLRequestClient', () => {
result: 'Hello world...',
},
});
const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 3 });
const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 2 });
spy.on(graphQLClient['client'], 'request');

const data = await graphQLClient.request('test');
Expand All @@ -177,7 +180,7 @@ describe('GraphQLRequestClient', () => {
});

it('should use [retry-after] header value when response is 429', async function() {
this.timeout(6000);
this.timeout(7000);
nock('http://jssnextweb')
.post('/graphql')
.reply(429, {}, { 'Retry-After': '2' });
Expand All @@ -186,15 +189,17 @@ describe('GraphQLRequestClient', () => {

await graphQLClient.request('test').catch(() => {
expect(graphQLClient['debug']).to.have.been.called.with(
'Error: Rate limit reached for GraphQL endpoint. Retrying in %ds. Retries left: %d',
2,
'Error: %d. Rate limit reached for GraphQL endpoint. Retrying in %dms (attempt %d).',
429,
2000,
1
);
spy.restore(graphQLClient);
});
});

it('should throw error when request is aborted with default timeout value after retry', async () => {
it('should throw error when request is aborted value after retry', async function() {
this.timeout(3000);
nock('http://jssnextweb')
.post('/graphql')
.reply(429)
Expand All @@ -206,13 +211,18 @@ describe('GraphQLRequestClient', () => {
},
});

const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 2 });
const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 1, timeout: 50 });
spy.on(graphQLClient['client'], 'request');
await graphQLClient.request('test').catch((error) => {
try {
await graphQLClient.request('test');
// If the request does not throw an error, fail the test
expect.fail('Expected request to throw an error');
} catch (error) {
expect(graphQLClient['client'].request).to.be.called.exactly(2);
expect(error.name).to.equal('AbortError');
} finally {
spy.restore(graphQLClient);
});
}
});

it('should throw error upon request timeout using provided timeout value', async () => {
Expand Down Expand Up @@ -245,4 +255,154 @@ describe('GraphQLRequestClient', () => {
expect(client['timeout']).to.equal(300);
});
});

describe('Retrayable status codes', () => {
const retryableStatusCodeThrowError = async (statusCode: number) => {
nock('http://jssnextweb')
.post('/graphql')
.reply(statusCode)
.post('/graphql')
.reply(statusCode)
.post('/graphql')
.reply(statusCode);

const graphQLClient = new GraphQLRequestClient(endpoint, {
retries: 2,
});

spy.on(graphQLClient['client'], 'request');

try {
await graphQLClient.request('test');
} catch (error) {
expect(error).to.not.be.undefined;
expect(graphQLClient['client'].request).to.have.been.called.exactly(3);
spy.restore(graphQLClient);
}
};

// Test cases for each retryable status code
for (const statusCode of [429, 502, 503, 504, 520, 521, 522, 523, 524]) {
it(`should retry and throw error for ${statusCode} when retries specified`, async function() {
this.timeout(8000);
await retryableStatusCodeThrowError(statusCode);
});
}

const retryableStatusCodeResolve = async (statusCode: number) => {
nock('http://jssnextweb')
.post('/graphql')
.reply(statusCode)
.post('/graphql')
.reply(statusCode)
.post('/graphql')
.reply(200, {
data: {
result: 'Hello world...',
},
});

const graphQLClient = new GraphQLRequestClient(endpoint, {
retries: 3,
});

spy.on(graphQLClient['client'], 'request');

const data = await graphQLClient.request('test');

try {
await graphQLClient.request('test');
expect(data).to.not.be.null;
} catch (error) {
expect(graphQLClient['client'].request).to.have.been.called.exactly(4);
spy.restore(graphQLClient);
}
};

// Test cases for each retryable status code
for (const statusCode of [429, 502, 503, 504, 520, 521, 522, 523, 524]) {
it(`should retry and resolve for ${statusCode} if one of the request resolves`, async function() {
this.timeout(16000);
await retryableStatusCodeResolve(statusCode);
});
}

it('should retry based on custom retryStrategy', async function() {
this.timeout(8000);

nock('http://jssnextweb')
.post('/graphql')
.reply(502, {
data: {
result: 'Hello world...',
},
});

const customRetryStrategy = {
shouldRetry: (_: any, attempt: number) => attempt <= 3,
getDelay: () => 1000,
};

const graphQLClient = new GraphQLRequestClient(endpoint, {
retries: 4,
retryStrategy: customRetryStrategy,
});

spy.on(graphQLClient['client'], 'request');

try {
await graphQLClient.request('test');
} catch (error) {
expect(error).to.not.be.undefined;
expect(graphQLClient['client'].request).to.be.called.exactly(4);
addy-pathania marked this conversation as resolved.
Show resolved Hide resolved
spy.restore(graphQLClient);
}
});

it('should delay before retrying based on exponential backoff', async function() {
this.timeout(32000);

nock('http://jssnextweb')
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429);

const graphQLClient = new GraphQLRequestClient(endpoint, {
retries: 4,
});

spy.on(graphQLClient['client'], 'request');

try {
await graphQLClient.request('test');

expect(graphQLClient['client'].request).to.have.been.called.exactly(1);

const clock = sinon.useFakeTimers();
clock.tick(1000);

await graphQLClient.request('test');
expect(graphQLClient['client'].request).to.have.been.called.exactly(2);

clock.tick(2000);

await graphQLClient.request('test');
expect(graphQLClient['client'].request).to.have.been.called.exactly(3);

clock.tick(4000);

await graphQLClient.request('test');
expect(graphQLClient['client'].request).to.have.been.called.exactly(4);

clock.restore();
} catch (error) {
console.log('error');
}
});
});
});
Loading