Skip to content

Commit

Permalink
[sitecore-jss][templates/nextjs] Handle rate limit errors in Layout a…
Browse files Browse the repository at this point in the history
…nd Dictionary Services through GraphQL Client (#1618)

* Retryer in graphql client implementation
  • Loading branch information
art-alexeyenko authored Sep 25, 2023
1 parent 1ed1ea4 commit d8fa093
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 12 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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))
* `[templates/nextjs]` `[sitecore-jss-nextjs]` Upgrade Nextjs to 13.4.16([#1616](https://github.com/Sitecore/jss/pull/1616))

### 🧹 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: 2 });
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: 1 });
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',
2,
1
);
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
37 changes: 30 additions & 7 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 || 0;
this.client = new Client(endpoint, {
headers: this.headers,
fetch: clientConfig.fetch,
Expand All @@ -82,9 +88,9 @@ export class GraphQLRequestClient implements GraphQLClient {
query: string | DocumentNode,
variables?: { [key: string]: unknown }
): Promise<T> {
const startTimestamp = Date.now();
let retriesLeft = this.retries;

return new Promise((resolve, reject) => {
const retryer = async (): Promise<T> => {
// 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.
this.debug('request: %o', {
Expand All @@ -93,24 +99,41 @@ export class GraphQLRequestClient implements GraphQLClient {
query,
variables,
});

const startTimestamp = Date.now();
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(
return Promise.race(fetchWithOptionalTimeout).then(
(data: T) => {
this.abortTimeout?.clear();
this.debug('response in %dms: %o', Date.now() - startTimestamp, data);
resolve(data);
return Promise.resolve(data);
},
(error: ClientError) => {
this.abortTimeout?.clear();
this.debug('response error: %o', error.response || error.message || error);
reject(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
);
retriesLeft--;
return new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000)).then(retryer);
} else {
return Promise.reject(error);
}
}
);
});
};

return retryer();
}
}
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

0 comments on commit d8fa093

Please sign in to comment.