Skip to content

Commit

Permalink
Retryer in graphql client implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
art-alexeyenko committed Sep 24, 2023
1 parent 774db8f commit dc7f370
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 27 deletions.
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.
For this, specify the number of retries the GraphQL server 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,12 @@ export class LayoutServiceFactory {
endpoint: config.graphQLEndpoint,
apiKey: config.sitecoreApiKey,
siteName,
/*
GraphQL Dictionary and Layout Services can handle 429 code errors from server.
For this, specify the number of retries the GraphQL server 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(
'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
78 changes: 60 additions & 18 deletions packages/sitecore-jss/src/graphql-request-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,20 @@ export interface GraphQLClient {
* @param {string | DocumentNode} query graphql query
* @param {Object} variables graphql variables
*/
request<T>(query: string | DocumentNode, variables?: { [key: string]: unknown }): Promise<T>;
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 {
/**
* Number of retries to pass into graphql client configuration
*/
retries?: number
}

/**
Expand All @@ -36,6 +49,10 @@ export type GraphQLRequestClientConfig = {
* GraphQLClient request timeout
*/
timeout?: number;
/**
* Number of retries for client
*/
retries?: number;
};

/**
Expand All @@ -48,6 +65,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 +84,7 @@ export class GraphQLRequestClient implements GraphQLClient {
}

this.timeout = clientConfig.timeout;
this.retries = clientConfig.retries;
this.client = new Client(endpoint, {
headers: this.headers,
fetch: clientConfig.fetch,
Expand All @@ -82,8 +101,6 @@ export class GraphQLRequestClient implements GraphQLClient {
query: string | DocumentNode,
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 +110,48 @@ export class GraphQLRequestClient implements GraphQLClient {
query,
variables,
});
let retriesLeft = this.retries || 1;

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',
delaySeconds,
retriesLeft
);
return new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000)).then(() => {
return retryer();
});
} else {
return Promise.reject(error);
}
}
);
};
retryer().then(
(data) => resolve(data),
(error: ClientError) => reject(error)
);
});
}
Expand Down
9 changes: 5 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,4 @@
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 +48,7 @@ const query = /* GraphQL */ `
/**
* Configuration options for @see GraphQLDictionaryService instances
*/
export interface GraphQLDictionaryServiceConfig extends SearchServiceConfig, CacheOptions {
export interface GraphQLDictionaryServiceConfig extends SearchServiceConfig, CacheOptions, GraphQLServiceRetryConfig {
/**
* The URL of the graphQL endpoint.
*/
Expand Down Expand Up @@ -95,7 +95,7 @@ export class GraphQLDictionaryService extends DictionaryServiceBase {
*/
constructor(public options: GraphQLDictionaryServiceConfig) {
super(options);
this.graphQLClient = this.getGraphQLClient();
this.graphQLClient = this.getGraphQLClient(options.retries);
this.searchService = new SearchQueryService<DictionaryQueryResult>(this.graphQLClient);
}

Expand Down Expand Up @@ -153,10 +153,11 @@ export class GraphQLDictionaryService extends DictionaryServiceBase {
* want to use something else.
* @returns {GraphQLClient} implementation
*/
protected getGraphQLClient(): GraphQLClient {
protected getGraphQLClient(retries?: number): GraphQLClient {
return new GraphQLRequestClient(this.options.endpoint, {
apiKey: this.options.apiKey,
debugger: debug.dictionary,
retries,
});
}
}
11 changes: 6 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,9 @@
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 {
/**
* Your Graphql endpoint
*/
Expand Down Expand Up @@ -44,7 +44,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 @@ -80,10 +80,11 @@ export class GraphQLLayoutService extends LayoutServiceBase {
* want to use something else.
* @returns {GraphQLClient} implementation
*/
protected getGraphQLClient(): GraphQLClient {
protected getGraphQLClient(retries?: number): GraphQLClient {
return new GraphQLRequestClient(this.serviceConfig.endpoint, {
apiKey: this.serviceConfig.apiKey,
debugger: debug.layout,
retries
});
}

Expand All @@ -108,4 +109,4 @@ export class GraphQLLayoutService extends LayoutServiceBase {
}
}`;
}
}
}

0 comments on commit dc7f370

Please sign in to comment.