From d8fa093572cfd39a9f4e7927ad0486caa48e0342 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Mon, 25 Sep 2023 12:44:58 -0400 Subject: [PATCH] [sitecore-jss][templates/nextjs] Handle rate limit errors in Layout and Dictionary Services through GraphQL Client (#1618) * Retryer in graphql client implementation --- CHANGELOG.md | 1 + .../src/lib/dictionary-service-factory.ts | 7 ++ .../nextjs/src/lib/layout-service-factory.ts | 7 ++ .../src/graphql-request-client.test.ts | 81 +++++++++++++++++++ .../src/graphql-request-client.ts | 37 +++++++-- .../src/i18n/graphql-dictionary-service.ts | 12 ++- .../src/layout/graphql-layout-service.ts | 11 ++- 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d8adc2f8..36bfaee723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts index 8ccfffde9c..dc1c81e32c 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts @@ -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, diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts index c471eacb3b..07a7803981 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts @@ -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, diff --git a/packages/sitecore-jss/src/graphql-request-client.test.ts b/packages/sitecore-jss/src/graphql-request-client.test.ts index 51bbd2e93b..89d76fddb9 100644 --- a/packages/sitecore-jss/src/graphql-request-client.test.ts +++ b/packages/sitecore-jss/src/graphql-request-client.test.ts @@ -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'; @@ -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') diff --git a/packages/sitecore-jss/src/graphql-request-client.ts b/packages/sitecore-jss/src/graphql-request-client.ts index 6300a176a1..3e9dac24d7 100644 --- a/packages/sitecore-jss/src/graphql-request-client.ts +++ b/packages/sitecore-jss/src/graphql-request-client.ts @@ -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; }; /** @@ -46,6 +50,7 @@ export class GraphQLRequestClient implements GraphQLClient { private client: Client; private headers: Record = {}; private debug: Debugger; + private retries: number; private abortTimeout?: TimeoutPromise; private timeout?: number; @@ -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, @@ -82,9 +88,9 @@ export class GraphQLRequestClient implements GraphQLClient { query: string | DocumentNode, variables?: { [key: string]: unknown } ): Promise { - const startTimestamp = Date.now(); + let retriesLeft = this.retries; - return new Promise((resolve, reject) => { + const retryer = async (): Promise => { // 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', { @@ -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(); } } diff --git a/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts b/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts index 50be55aee1..e378bdfcc5 100644 --- a/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts +++ b/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts @@ -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'; @@ -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 { /** * The URL of the graphQL endpoint. */ @@ -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, }); } } diff --git a/packages/sitecore-jss/src/layout/graphql-layout-service.ts b/packages/sitecore-jss/src/layout/graphql-layout-service.ts index 187986d101..5f7c59b9a2 100644 --- a/packages/sitecore-jss/src/layout/graphql-layout-service.ts +++ b/packages/sitecore-jss/src/layout/graphql-layout-service.ts @@ -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 { /** * Your Graphql endpoint */ @@ -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. @@ -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, }); }