Skip to content

Commit

Permalink
Merge #1612 #1618 #1619 for release 20.2 (#1631)
Browse files Browse the repository at this point in the history
* [sitecore-jss-nextjs] Reduce the amount of Edge API calls during fetch `getStaticPaths`  (#1612)

* [sitecore-jss][templates/nextjs][templates/nextjs-sxa] Handle rate limit errors in Layout and Dictionary Services through GraphQL Client

* Updated CHANGELOG
  • Loading branch information
illiakovalenko authored Oct 12, 2023
1 parent 8591056 commit db870a9
Show file tree
Hide file tree
Showing 12 changed files with 188 additions and 30 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. The format

This project does NOT adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and major versions of this project denote compatibility with Sitecore Platform versions. Refer to the "Headless Services" section in the [Sitecore modules compatibility table](https://support.sitecore.com/kb?id=kb_article_view&sysparm_article=KB1000576) or the [Headless Rendering download page](https://dev.sitecore.net/Downloads/Sitecore_Headless_Rendering.aspx) for more details on versioning.

## 20.2.0

### 🎉 New Features & Improvements

* `[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 ([commit](https://github.com/Sitecore/jss/pull/1631/commits/d39d74ad7bbeddcb66b7de4377070e178851abc5))([#1631](https://github.com/Sitecore/jss/pull/1631))
* `[sitecore-jss-nextjs]` Reduce the amount of Edge API calls during fetch getStaticPaths ([commit](https://github.com/Sitecore/jss/pull/1631/commits/cd2771b256ac7c38818ee6bea48278958ac455ca))([#1631](https://github.com/Sitecore/jss/pull/1631))

## 20.1.0

### 🎉 New Features & Improvements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class ErrorPagesPlugin implements Plugin {
apiKey: config.sitecoreApiKey,
siteName: config.jssAppName,
language: props.locale,
retries:
(process.env.GRAPH_QL_SERVICE_RETRIES &&
parseInt(process.env.GRAPH_QL_SERVICE_RETRIES, 10)) ||
0,
});

if (props.notFound) {
Expand Down
3 changes: 3 additions & 0 deletions packages/create-sitecore-jss/src/templates/nextjs/.env
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ SITECORE_API_HOST=
# the resolved Sitecore API hostname + the `graphQLEndpointPath` defined in your `package.json`.
GRAPH_QL_ENDPOINT=

# How many times should GraphQL Layout, Dictionary and ErrorPages services retry a fetch when endpoint rate limit is reached
GRAPH_QL_SERVICE_RETRIES=0

# The way in which layout and dictionary data is fetched from Sitecore
FETCH_WITH=<%- fetchWith %>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export class DictionaryServiceFactory {
otherwise, the service will attempt to figure out the root item for the current JSS App using GraphQL and app name.
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'
*/
retries:
(process.env.GRAPH_QL_SERVICE_RETRIES &&
parseInt(process.env.GRAPH_QL_SERVICE_RETRIES, 10)) ||
0,
})
: new RestDictionaryService({
apiHost: config.sitecoreApiHost,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ export class LayoutServiceFactory {
endpoint: config.graphQLEndpoint,
apiKey: config.sitecoreApiKey,
siteName: config.jssAppName,
/*
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'
*/
retries:
(process.env.GRAPH_QL_SERVICE_RETRIES &&
parseInt(process.env.GRAPH_QL_SERVICE_RETRIES, 10)) ||
0,
})
: new RestLayoutService({
apiHost: config.sitecoreApiHost,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,17 @@ describe('GraphQLSitemapService', () => {
});
});

it('should throw error if empty language is provided', async () => {
mockPathsRequest();

const service = new GraphQLSitemapService({ endpoint, apiKey, siteName });
await service.fetchSSGSitemap(['']).catch((error: RangeError) => {
expect(error.message).to.equal('The language must be a non-empty string');
});

return expect(nock.isDone()).to.be.false;
});

it('should use a custom pageSize, if provided', async () => {
const customPageSize = 20;

Expand All @@ -257,7 +268,7 @@ describe('GraphQLSitemapService', () => {
.post(
'/',
(body) =>
body.query.indexOf('$pageSize: Int = 10') > 0 && body.variables.pageSize === undefined
body.query.indexOf('$pageSize: Int = 100') > 0 && body.variables.pageSize === undefined
)
.reply(200, sitemapQueryResult);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ export const queryError =
/** @private */
export const languageError = 'The list of languages cannot be empty';

/** @private */
const languageEmptyError = 'The language must be a non-empty string';

// Even though _hasLayout should always be "true" in this query, using a variable is necessary for compatibility with Edge
const defaultQuery = /* GraphQL */ `
query SitemapQuery(
$rootItemId: String!
$language: String!
$pageSize: Int = 10
$pageSize: Int = 100
$hasLayout: String = "true"
$after: String
) {
Expand Down Expand Up @@ -173,23 +176,29 @@ export class GraphQLSitemapService {
throw new Error(queryError);
}

// Fetch paths using all locales
const paths = await Promise.all(
languages.map((language) => {
debug.sitemap('fetching sitemap data for %s', language);
return this.searchService
.fetch(this.query, {
rootItemId,
language,
pageSize: this.options.pageSize,
})
.then((results) => {
return results.map((item) =>
formatStaticPath(item.url.path.replace(/^\/|\/$/g, '').split('/'), language)
);
});
})
);
const paths: StaticPath[] = [];

for (const language of languages) {
if (language === '') {
throw new RangeError(languageEmptyError);
}

debug.sitemap('fetching sitemap data for %s', language);

const languagePaths = await this.searchService
.fetch(this.query, {
rootItemId,
language,
pageSize: this.options.pageSize,
})
.then((results) => {
return results.map((item) =>
formatStaticPath(item.url.path.replace(/^\/|\/$/g, '').split('/'), language)
);
});

paths.push(...languagePaths);
}

// merge promises results into single result
return ([] as StaticPath[]).concat(...paths);
Expand Down
60 changes: 60 additions & 0 deletions packages/sitecore-jss/src/graphql-request-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable dot-notation */
/* eslint-disable no-unused-expressions */
import { expect, use, spy } from 'chai';
import spies from 'chai-spies';
Expand Down Expand Up @@ -117,4 +118,63 @@ 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);
});
});
});
38 changes: 33 additions & 5 deletions packages/sitecore-jss/src/graphql-request-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export type GraphQLRequestClientConfig = {
* Override fetch method. Uses 'graphql-request' library default otherwise ('cross-fetch').
*/
fetch?: typeof fetch;
/**
* Number of retries for client. Will be used if endpoint responds with 429 (rate limit reached) error
*/
retries?: number;
};

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

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

this.retries = clientConfig.retries || 0;
this.client = new Client(endpoint, { headers: this.headers, fetch: clientConfig.fetch });
this.debug = clientConfig.debugger || debuggers.http;
}
Expand All @@ -71,7 +77,9 @@ export class GraphQLRequestClient implements GraphQLClient {
query: string | DocumentNode,
variables?: { [key: string]: unknown }
): Promise<T> {
return new Promise((resolve, reject) => {
let retriesLeft = this.retries;

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 @@ -81,16 +89,36 @@ export class GraphQLRequestClient implements GraphQLClient {
variables,
});

this.client
return this.client
.request(query, variables)
.then((data: T) => {
this.debug('response: %o', data);
resolve(data);
return data;
})
.catch((error: ClientError) => {
this.debug('response error: %o', error.response);
return 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);
}

throw 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 @@ -155,6 +162,7 @@ export class GraphQLDictionaryService extends DictionaryServiceBase {
return new GraphQLRequestClient(this.options.endpoint, {
apiKey: this.options.apiKey,
debugger: debug.dictionary,
retries: this.options.retries,
});
}
}
9 changes: 7 additions & 2 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 type GraphQLLayoutServiceConfig = Pick<GraphQLRequestClientConfig, 'retries'> & {
/**
* Your Graphql endpoint
*/
Expand Down Expand Up @@ -79,6 +83,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
5 changes: 3 additions & 2 deletions packages/sitecore-jss/src/site/graphql-error-pages-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GraphQLClient, GraphQLRequestClient } from '../graphql';
import { GraphQLClient, GraphQLRequestClient, GraphQLRequestClientConfig } from '../graphql';
import { siteNameError } from '../constants';
import debug from '../debug';

Expand All @@ -16,7 +16,7 @@ const defaultQuery = /* GraphQL */ `
}
`;

export type GraphQLErrorPagesServiceConfig = {
export type GraphQLErrorPagesServiceConfig = Pick<GraphQLRequestClientConfig, 'retries'> & {
/**
* Your Graphql endpoint
*/
Expand Down Expand Up @@ -98,6 +98,7 @@ export class GraphQLErrorPagesService {
return new GraphQLRequestClient(this.options.endpoint, {
apiKey: this.options.apiKey,
debugger: debug.errorpages,
retries: this.options.retries,
});
}
}

0 comments on commit db870a9

Please sign in to comment.