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][templates/nextjs] Handle rate limit errors in Layout and Dictionary Services through GraphQL Client #1618

Merged
merged 10 commits into from
Sep 25, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Our versioning strategy is as follows:
* `[sitecore-jss-dev-tools]` `[templates/nextjs]` `[templates/react]` Introduce "components" configuration for ComponentBuilder ([#1598](https://github.com/Sitecore/jss/pull/1598))
* `[sitecore-jss-react]` `[sitecore-jss-nextjs]` Component level data fetching(SSR/SSG) for BYOC ([#1610](https://github.com/Sitecore/jss/pull/1610))
* `[sitecore-jss-nextjs]` Reduce the amount of Edge API calls during fetch getStaticPaths ([#1612](https://github.com/Sitecore/jss/pull/1612))
* `[sitecore-jss]` `[templates/nextjs]` GraphQL Layout and Dictionary services can handle endpoint rate limits through retryer functionality in GraphQLClient. To prevent SSG builds from failing and enable multiple retries, set retry amount in lib/dictionary-service-factory and lib/layout-service-factory ([#1618](https://github.com/Sitecore/jss/pull/1618))

### 🧹 Chores

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export class DictionaryServiceFactory {
Otherwise, if your Sitecore instance only has 1 JSS App (i.e. in a Sitecore XP setup), you can specify the root item ID here.
rootItemId: '{GUID}'
*/
/*
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.
It will only try the request once by default.
retries: 'number'
*/
})
: new RestDictionaryService({
apiHost: config.sitecoreApiHost,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export class LayoutServiceFactory {
endpoint: config.graphQLEndpoint,
apiKey: config.sitecoreApiKey,
siteName,
/*
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.
It will only try the request once by default.
retries: 'number'
*/
})
: new RestLayoutService({
apiHost: config.sitecoreApiHost,
Expand Down
81 changes: 81 additions & 0 deletions packages/sitecore-jss/src/graphql-request-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable dot-notation */
import { expect, use, spy } from 'chai';
import spies from 'chai-spies';
import nock from 'nock';
Expand Down Expand Up @@ -134,6 +135,86 @@ describe('GraphQLRequestClient', () => {
});
});

it('should use retry and throw error when retries specified', async function() {
this.timeout(6000);
nock('http://jssnextweb')
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429);
const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 3 });
spy.on(graphQLClient['client'], 'request');
await graphQLClient.request('test').catch((error) => {
expect(error).to.not.be.undefined;
expect(graphQLClient['client'].request).to.be.called.exactly(3);
spy.restore(graphQLClient);
});
});

it('should use retry and resolve if one of the requests resolves', async function() {
this.timeout(6000);
nock('http://jssnextweb')
.post('/graphql')
.reply(429)
.post('/graphql')
.reply(429)
.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');

expect(data).to.not.be.null;
expect(graphQLClient['client'].request).to.be.called.exactly(3);
spy.restore(graphQLClient);
});

it('should use [retry-after] header value when response is 429', async function() {
this.timeout(6000);
nock('http://jssnextweb')
.post('/graphql')
.reply(429, {}, { 'Retry-After': '2' });
const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 2 });
spy.on(graphQLClient, 'debug');

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',
1,
2
);
spy.restore(graphQLClient);
});
});

it('should throw error when request is aborted with default timeout value after retry', async () => {
nock('http://jssnextweb')
.post('/graphql')
.reply(429)
.post('/graphql')
.delay(100)
.reply(200, {
data: {
result: 'Hello world...',
},
});

const graphQLClient = new GraphQLRequestClient(endpoint, { retries: 2 });
spy.on(graphQLClient['client'], 'request');
await graphQLClient.request('test').catch((error) => {
expect(graphQLClient['client'].request).to.be.called.exactly(2);
expect(error.name).to.equal('AbortError');
spy.restore(graphQLClient);
});
});

it('should throw error upon request timeout using provided timeout value', async () => {
nock('http://jssnextweb')
.post('/graphql')
Expand Down
63 changes: 46 additions & 17 deletions packages/sitecore-jss/src/graphql-request-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export type GraphQLRequestClientConfig = {
* GraphQLClient request timeout
*/
timeout?: number;
/**
* Number of retries for client. Will be used if endpoint responds with 429 (rate limit reached) error
*/
retries?: number;
};

/**
Expand All @@ -46,6 +50,7 @@ export class GraphQLRequestClient implements GraphQLClient {
private client: Client;
private headers: Record<string, string> = {};
private debug: Debugger;
private retries: number;
private abortTimeout?: TimeoutPromise;
private timeout?: number;

Expand All @@ -66,6 +71,7 @@ export class GraphQLRequestClient implements GraphQLClient {
}

this.timeout = clientConfig.timeout;
this.retries = clientConfig.retries || 1;
this.client = new Client(endpoint, {
headers: this.headers,
fetch: clientConfig.fetch,
Expand All @@ -82,8 +88,6 @@ export class GraphQLRequestClient implements GraphQLClient {
query: string | DocumentNode,
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
variables?: { [key: string]: unknown }
): Promise<T> {
const startTimestamp = Date.now();

return new Promise((resolve, reject) => {
// Note we don't have access to raw request/response with graphql-request
// (or nice hooks like we have with Axios), but we should log whatever we have.
Expand All @@ -93,23 +97,48 @@ export class GraphQLRequestClient implements GraphQLClient {
query,
variables,
});
let retriesLeft = this.retries;

const fetchWithOptionalTimeout = [this.client.request(query, variables)];
if (this.timeout) {
this.abortTimeout = new TimeoutPromise(this.timeout);
fetchWithOptionalTimeout.push(this.abortTimeout.start);
}
Promise.race(fetchWithOptionalTimeout).then(
(data: T) => {
this.abortTimeout?.clear();
this.debug('response in %dms: %o', Date.now() - startTimestamp, data);
resolve(data);
},
(error: ClientError) => {
this.abortTimeout?.clear();
this.debug('response error: %o', error.response || error.message || error);
reject(error);
const retryer = async (): Promise<T> => {
const startTimestamp = Date.now();
retriesLeft--;
const fetchWithOptionalTimeout = [this.client.request(query, variables)];
if (this.timeout) {
this.abortTimeout = new TimeoutPromise(this.timeout);
fetchWithOptionalTimeout.push(this.abortTimeout.start);
}
return Promise.race(fetchWithOptionalTimeout).then(
(data: T) => {
this.abortTimeout?.clear();
this.debug('response in %dms: %o', Date.now() - startTimestamp, data);
return Promise.resolve(data);
},
(error: ClientError) => {
this.abortTimeout?.clear();
this.debug('response error: %o', error.response || error.message || error);
if (error.response?.status === 429 && retriesLeft > 0) {
const rawHeaders = (error as ClientError)?.response?.headers;
const delaySeconds =
rawHeaders && rawHeaders.get('Retry-After')
? Number.parseInt(rawHeaders.get('Retry-After'), 10)
: 1;
this.debug(
'Error: Rate limit reached for GraphQL endpoint. Retrying in %ds. Retries left: %d',
delaySeconds,
retriesLeft
);
return new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000)).then(
retryer
);
} else {
return Promise.reject(error);
}
}
);
};
retryer().then(
(data) => resolve(data),
(error: ClientError) => reject(error)
);
});
}
Expand Down
12 changes: 10 additions & 2 deletions packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { GraphQLClient, GraphQLRequestClient } from '../graphql-request-client';
import {
GraphQLClient,
GraphQLRequestClient,
GraphQLRequestClientConfig,
} from '../graphql-request-client';
import { SitecoreTemplateId } from '../constants';
import { DictionaryPhrases, DictionaryServiceBase } from './dictionary-service';
import { CacheOptions } from '../cache-client';
Expand Down Expand Up @@ -48,7 +52,10 @@ const query = /* GraphQL */ `
/**
* Configuration options for @see GraphQLDictionaryService instances
*/
export interface GraphQLDictionaryServiceConfig extends SearchServiceConfig, CacheOptions {
export interface GraphQLDictionaryServiceConfig
extends SearchServiceConfig,
CacheOptions,
Pick<GraphQLRequestClientConfig, 'retries'> {
/**
* The URL of the graphQL endpoint.
*/
Expand Down Expand Up @@ -157,6 +164,7 @@ export class GraphQLDictionaryService extends DictionaryServiceBase {
return new GraphQLRequestClient(this.options.endpoint, {
apiKey: this.options.apiKey,
debugger: debug.dictionary,
retries: this.options.retries,
});
}
}
11 changes: 8 additions & 3 deletions packages/sitecore-jss/src/layout/graphql-layout-service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { LayoutServiceBase } from './layout-service';
import { LayoutServiceData } from './models';
import { GraphQLClient, GraphQLRequestClient } from '../graphql-request-client';
import {
GraphQLClient,
GraphQLRequestClient,
GraphQLRequestClientConfig,
} from '../graphql-request-client';
import debug from '../debug';

export type GraphQLLayoutServiceConfig = {
export interface GraphQLLayoutServiceConfig extends Pick<GraphQLRequestClientConfig, 'retries'> {
/**
* Your Graphql endpoint
*/
Expand All @@ -28,7 +32,7 @@ export type GraphQLLayoutServiceConfig = {
* layout(site:"${siteName}", routePath:"${itemPath}", language:"${language}")
*/
formatLayoutQuery?: (siteName: string, itemPath: string, locale?: string) => string;
};
}

/**
* Service that fetch layout data using Sitecore's GraphQL API.
Expand Down Expand Up @@ -84,6 +88,7 @@ export class GraphQLLayoutService extends LayoutServiceBase {
return new GraphQLRequestClient(this.serviceConfig.endpoint, {
apiKey: this.serviceConfig.apiKey,
debugger: debug.layout,
retries: this.serviceConfig.retries,
});
}

Expand Down
Loading