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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ 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 Dictionary and Layout Services can handle 429 code errors from server.
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
For this, specify the number of retries the GraphQL server will attempt.
It will only try the request once by default
retries: %number%
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
*/
})
: new RestDictionaryService({
apiHost: config.sitecoreApiHost,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export class LayoutServiceFactory {
endpoint: config.graphQLEndpoint,
apiKey: config.sitecoreApiKey,
siteName,
/*
GraphQL Dictionary and Layout Services can handle 429 code errors from server.
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
For this, specify the number of retries the GraphQL server will attempt.
It will only try the request once by default
retries: %number%
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
*/
})
: 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(
'Endpoint responded with 429. 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
73 changes: 56 additions & 17 deletions packages/sitecore-jss/src/graphql-request-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export interface GraphQLClient {
request<T>(query: string | DocumentNode, variables?: { [key: string]: unknown }): Promise<T>;
}

/**
* Interface for graphql services that utilize retry functionality from GraphQL client
*/
export interface GraphQLServiceRetryConfig {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
/**
* Number of retries to pass into graphql client configuration
*/
retries?: number;
}

/**
* Minimum configuration options for classes that implement @see GraphQLClient
*/
Expand All @@ -36,6 +46,10 @@ export type GraphQLRequestClientConfig = {
* GraphQLClient request timeout
*/
timeout?: number;
/**
* Number of retries for client
*/
retries?: number;
};

/**
Expand All @@ -48,6 +62,7 @@ export class GraphQLRequestClient implements GraphQLClient {
private debug: Debugger;
private abortTimeout?: TimeoutPromise;
private timeout?: number;
private retries?: number;

/**
* Provides ability to execute graphql query using given `endpoint`
Expand All @@ -66,6 +81,7 @@ export class GraphQLRequestClient implements GraphQLClient {
}

this.timeout = clientConfig.timeout;
this.retries = clientConfig.retries;
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
this.client = new Client(endpoint, {
headers: this.headers,
fetch: clientConfig.fetch,
Expand All @@ -82,8 +98,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 +107,48 @@ export class GraphQLRequestClient implements GraphQLClient {
query,
variables,
});
let retriesLeft = this.retries || 1;
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved

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(
'Endpoint responded with 429. Retrying in %ds. Retries left: %d',
illiakovalenko marked this conversation as resolved.
Show resolved Hide resolved
delaySeconds,
retriesLeft
);
return new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000)).then(() => {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
return retryer();
});
} else {
return Promise.reject(error);
}
}
);
};
retryer().then(
(data) => resolve(data),
(error: ClientError) => reject(error)
);
});
}
Expand Down
17 changes: 13 additions & 4 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,
GraphQLServiceRetryConfig,
} 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,
GraphQLServiceRetryConfig {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
/**
* The URL of the graphQL endpoint.
*/
Expand Down Expand Up @@ -95,7 +102,7 @@ export class GraphQLDictionaryService extends DictionaryServiceBase {
*/
constructor(public options: GraphQLDictionaryServiceConfig) {
super(options);
this.graphQLClient = this.getGraphQLClient();
this.graphQLClient = this.getGraphQLClient(options.retries);
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
this.searchService = new SearchQueryService<DictionaryQueryResult>(this.graphQLClient);
}

Expand Down Expand Up @@ -151,12 +158,14 @@ export class GraphQLDictionaryService extends DictionaryServiceBase {
* Gets a GraphQL client that can make requests to the API. Uses graphql-request as the default
* library for fetching graphql data (@see GraphQLRequestClient). Override this method if you
* want to use something else.
* @param {number} retries number of retries a graphql client should attempt
* @returns {GraphQLClient} implementation
*/
protected getGraphQLClient(): GraphQLClient {
protected getGraphQLClient(retries?: number): GraphQLClient {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
return new GraphQLRequestClient(this.options.endpoint, {
apiKey: this.options.apiKey,
debugger: debug.dictionary,
retries,
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
});
}
}
16 changes: 11 additions & 5 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,
GraphQLServiceRetryConfig,
} from '../graphql-request-client';
import debug from '../debug';

export type GraphQLLayoutServiceConfig = {
export interface GraphQLLayoutServiceConfig extends GraphQLServiceRetryConfig {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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 All @@ -44,7 +48,7 @@ export class GraphQLLayoutService extends LayoutServiceBase {
*/
constructor(public serviceConfig: GraphQLLayoutServiceConfig) {
super();
this.graphQLClient = this.getGraphQLClient();
this.graphQLClient = this.getGraphQLClient(serviceConfig.retries);
}

/**
Expand Down Expand Up @@ -78,12 +82,14 @@ export class GraphQLLayoutService extends LayoutServiceBase {
* Gets a GraphQL client that can make requests to the API. Uses graphql-request as the default
* library for fetching graphql data (@see GraphQLRequestClient). Override this method if you
* want to use something else.
* @param {number} retries number of retries a graphql client should attempt
* @returns {GraphQLClient} implementation
*/
protected getGraphQLClient(): GraphQLClient {
protected getGraphQLClient(retries?: number): GraphQLClient {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
return new GraphQLRequestClient(this.serviceConfig.endpoint, {
apiKey: this.serviceConfig.apiKey,
debugger: debug.layout,
retries,
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
});
}

Expand Down
Loading