Skip to content

Commit 65207a6

Browse files
authored
feat(api-rest): configurable retry behavior (#14303)
1 parent b188564 commit 65207a6

File tree

8 files changed

+282
-8
lines changed

8 files changed

+282
-8
lines changed

packages/api-rest/__tests__/apis/common/internalPost.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33

44
import { AmplifyClassV6 } from '@aws-amplify/core';
55
import { ApiError } from '@aws-amplify/core/internals/utils';
6-
import { parseJsonError } from '@aws-amplify/core/internals/aws-client-utils';
6+
import {
7+
getRetryDecider,
8+
parseJsonError,
9+
} from '@aws-amplify/core/internals/aws-client-utils';
710

811
import {
912
cancel,
@@ -21,6 +24,7 @@ jest.mock('../../../src/apis/common/baseHandlers/unauthenticatedHandler');
2124
const mockAuthenticatedHandler = jest.mocked(authenticatedHandler);
2225
const mockUnauthenticatedHandler = jest.mocked(unauthenticatedHandler);
2326
const mockParseJsonError = jest.mocked(parseJsonError);
27+
const mockGetRetryDecider = jest.mocked(getRetryDecider);
2428
const mockFetchAuthSession = jest.fn();
2529
const mockAmplifyInstance = {
2630
Auth: {
@@ -45,13 +49,17 @@ const credentials = {
4549
sessionToken: 'sessionToken',
4650
secretAccessKey: 'secretAccessKey',
4751
};
52+
const retryExpectedResponse = { retryable: true };
53+
const mockGetRetryDeciderResponse = () =>
54+
Promise.resolve(retryExpectedResponse);
4855

4956
describe('internal post', () => {
5057
beforeEach(() => {
5158
jest.resetAllMocks();
5259
mockFetchAuthSession.mockResolvedValue({ credentials });
5360
mockAuthenticatedHandler.mockResolvedValue(successResponse);
5461
mockUnauthenticatedHandler.mockResolvedValue(successResponse);
62+
mockGetRetryDecider.mockReturnValue(mockGetRetryDeciderResponse);
5563
});
5664

5765
it('should call authenticatedHandler with specified region from signingServiceInfo', async () => {
@@ -398,4 +406,52 @@ describe('internal post', () => {
398406
});
399407
}
400408
});
409+
410+
it('should use jittered-exponential-backoff retry strategy', async () => {
411+
expect.assertions(2);
412+
await post(mockAmplifyInstance, {
413+
url: apiGatewayUrl,
414+
options: {
415+
signingServiceInfo: {},
416+
},
417+
});
418+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
419+
expect.anything(),
420+
expect.objectContaining({ retryDecider: expect.any(Function) }),
421+
);
422+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
423+
const { retryDecider } = callArgs[1];
424+
const retryDeciderResult = await retryDecider();
425+
expect(retryDeciderResult).toEqual(retryExpectedResponse);
426+
});
427+
428+
it('should use jittered-exponential-backoff retry strategy, even when configuring using library options', async () => {
429+
expect.assertions(2);
430+
const mockAmplifyInstanceWithNoRetry = {
431+
...mockAmplifyInstance,
432+
libraryOptions: {
433+
API: {
434+
REST: {
435+
retryStrategy: {
436+
strategy: 'no-retry',
437+
},
438+
},
439+
},
440+
},
441+
} as any as AmplifyClassV6;
442+
await post(mockAmplifyInstanceWithNoRetry, {
443+
url: apiGatewayUrl,
444+
options: {
445+
signingServiceInfo: {},
446+
},
447+
});
448+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
449+
expect.anything(),
450+
expect.objectContaining({ retryDecider: expect.any(Function) }),
451+
);
452+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
453+
const { retryDecider } = callArgs[1];
454+
const retryDeciderResult = await retryDecider();
455+
expect(retryDeciderResult).toEqual(retryExpectedResponse);
456+
});
401457
});

packages/api-rest/__tests__/apis/common/publicApis.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { AmplifyClassV6 } from '@aws-amplify/core';
5-
import { parseJsonError } from '@aws-amplify/core/internals/aws-client-utils';
5+
import {
6+
getRetryDecider,
7+
parseJsonError,
8+
} from '@aws-amplify/core/internals/aws-client-utils';
69
import { ApiError } from '@aws-amplify/core/internals/utils';
710

811
import { authenticatedHandler } from '../../../src/apis/common/baseHandlers/authenticatedHandler';
@@ -75,6 +78,10 @@ const mockSuccessResponse = {
7578
text: jest.fn(),
7679
},
7780
};
81+
const mockGetRetryDecider = getRetryDecider as jest.Mock;
82+
const mockRetryResponse = { retryable: true };
83+
const mockNoRetryResponse = { retryable: false };
84+
const mockRetryDeciderResponse = () => Promise.resolve(mockRetryResponse);
7885

7986
describe('public APIs', () => {
8087
beforeEach(() => {
@@ -86,6 +93,7 @@ describe('public APIs', () => {
8693
mockAuthenticatedHandler.mockResolvedValue(mockSuccessResponse);
8794
mockUnauthenticatedHandler.mockResolvedValue(mockSuccessResponse);
8895
mockGetConfig.mockReturnValue(mockConfig);
96+
mockGetRetryDecider.mockReturnValue(mockRetryDeciderResponse);
8997
});
9098
const APIs = [
9199
{ name: 'get', fn: get, method: 'GET' },
@@ -415,6 +423,160 @@ describe('public APIs', () => {
415423
expect(error.message).toBe(cancelMessage);
416424
}
417425
});
426+
427+
describe('retry strategy', () => {
428+
beforeEach(() => {
429+
mockAuthenticatedHandler.mockReset();
430+
mockAuthenticatedHandler.mockResolvedValue(mockSuccessResponse);
431+
});
432+
433+
it('should not retry when retry is set to "no-retry"', async () => {
434+
expect.assertions(2);
435+
await fn(mockAmplifyInstance, {
436+
apiName: 'restApi1',
437+
path: '/items',
438+
retryStrategy: {
439+
strategy: 'no-retry',
440+
},
441+
}).response;
442+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
443+
expect.any(Object),
444+
expect.objectContaining({ retryDecider: expect.any(Function) }),
445+
);
446+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
447+
const { retryDecider } = callArgs[1];
448+
const result = await retryDecider();
449+
expect(result).toEqual(mockNoRetryResponse);
450+
});
451+
452+
it('should retry when retry is set to "jittered-exponential-backoff"', async () => {
453+
expect.assertions(2);
454+
await fn(mockAmplifyInstance, {
455+
apiName: 'restApi1',
456+
path: '/items',
457+
retryStrategy: {
458+
strategy: 'jittered-exponential-backoff',
459+
},
460+
}).response;
461+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
462+
expect.any(Object),
463+
expect.objectContaining({ retryDecider: expect.any(Function) }),
464+
);
465+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
466+
const { retryDecider } = callArgs[1];
467+
const result = await retryDecider();
468+
expect(result).toEqual(mockRetryResponse);
469+
});
470+
471+
it('should retry when retry strategy is not provided', async () => {
472+
expect.assertions(2);
473+
await fn(mockAmplifyInstance, {
474+
apiName: 'restApi1',
475+
path: '/items',
476+
}).response;
477+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
478+
expect.any(Object),
479+
expect.objectContaining({ retryDecider: expect.any(Function) }),
480+
);
481+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
482+
const { retryDecider } = callArgs[1];
483+
const result = await retryDecider();
484+
expect(result).toEqual(mockRetryResponse);
485+
});
486+
487+
it('should retry and prefer the individual retry strategy over the library options', async () => {
488+
expect.assertions(2);
489+
const mockAmplifyInstanceWithNoRetry = {
490+
...mockAmplifyInstance,
491+
libraryOptions: {
492+
API: {
493+
REST: {
494+
retryStrategy: {
495+
strategy: 'no-retry',
496+
},
497+
},
498+
},
499+
},
500+
} as any as AmplifyClassV6;
501+
await fn(mockAmplifyInstanceWithNoRetry, {
502+
apiName: 'restApi1',
503+
path: 'items',
504+
retryStrategy: {
505+
strategy: 'jittered-exponential-backoff',
506+
},
507+
}).response;
508+
509+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
510+
expect.any(Object),
511+
expect.objectContaining({ retryDecider: expect.any(Function) }),
512+
);
513+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
514+
const { retryDecider } = callArgs[1];
515+
const result = await retryDecider();
516+
expect(result).toEqual(mockRetryResponse);
517+
});
518+
519+
it('should not retry and prefer the individual retry strategy over the library options', async () => {
520+
expect.assertions(2);
521+
const mockAmplifyInstanceWithRetry = {
522+
...mockAmplifyInstance,
523+
libraryOptions: {
524+
API: {
525+
REST: {
526+
retryStrategy: {
527+
strategy: 'jittered-exponential-backoff',
528+
},
529+
},
530+
},
531+
},
532+
} as any as AmplifyClassV6;
533+
await fn(mockAmplifyInstanceWithRetry, {
534+
apiName: 'restApi1',
535+
path: 'items',
536+
retryStrategy: {
537+
strategy: 'no-retry',
538+
},
539+
}).response;
540+
541+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
542+
expect.any(Object),
543+
expect.objectContaining({ retryDecider: expect.any(Function) }),
544+
);
545+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
546+
const { retryDecider } = callArgs[1];
547+
const result = await retryDecider();
548+
expect(result).toEqual(mockNoRetryResponse);
549+
});
550+
551+
it('should not retry when configured through library options', async () => {
552+
expect.assertions(2);
553+
const mockAmplifyInstanceWithRetry = {
554+
...mockAmplifyInstance,
555+
libraryOptions: {
556+
API: {
557+
REST: {
558+
retryStrategy: {
559+
strategy: 'no-retry',
560+
},
561+
},
562+
},
563+
},
564+
} as any as AmplifyClassV6;
565+
await fn(mockAmplifyInstanceWithRetry, {
566+
apiName: 'restApi1',
567+
path: 'items',
568+
}).response;
569+
570+
expect(mockAuthenticatedHandler).toHaveBeenCalledWith(
571+
expect.any(Object),
572+
expect.objectContaining({ retryDecider: expect.any(Function) }),
573+
);
574+
const callArgs = mockAuthenticatedHandler.mock.calls[0];
575+
const { retryDecider } = callArgs[1];
576+
const result = await retryDecider();
577+
expect(result).toEqual(mockNoRetryResponse);
578+
});
579+
});
418580
});
419581
});
420582
});

packages/api-rest/src/apis/common/internalPost.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ export const post = (
6666
method: 'POST',
6767
...options,
6868
abortSignal: controller.signal,
69+
retryStrategy: {
70+
strategy: 'jittered-exponential-backoff',
71+
},
6972
},
7073
isIamAuthApplicableForGraphQL,
7174
options?.signingServiceInfo,

packages/api-rest/src/apis/common/publicApis.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ const publicHandler = (
3535
method: string,
3636
) =>
3737
createCancellableOperation(async abortSignal => {
38-
const { apiName, options: apiOptions = {}, path: apiPath } = options;
38+
const {
39+
apiName,
40+
options: apiOptions = {},
41+
path: apiPath,
42+
retryStrategy,
43+
} = options;
3944
const url = resolveApiUrl(
4045
amplify,
4146
apiName,
@@ -71,6 +76,7 @@ const publicHandler = (
7176
method,
7277
headers,
7378
abortSignal,
79+
retryStrategy,
7480
},
7581
isIamAuthApplicableForRest,
7682
signingServiceInfo,

packages/api-rest/src/apis/common/transferHandler.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { AmplifyClassV6 } from '@aws-amplify/core';
44
import {
55
Headers,
66
HttpRequest,
7+
RetryOptions,
78
getRetryDecider,
89
jitteredBackoff,
910
} from '@aws-amplify/core/internals/aws-client-utils';
1011
import {
1112
AWSCredentials,
1213
DocumentType,
14+
RetryStrategy,
1315
} from '@aws-amplify/core/internals/utils';
1416

1517
import {
@@ -27,8 +29,11 @@ type HandlerOptions = Omit<HttpRequest, 'body' | 'headers'> & {
2729
body?: DocumentType | FormData;
2830
headers?: Headers;
2931
withCredentials?: boolean;
32+
retryStrategy?: RetryStrategy;
3033
};
3134

35+
type RetryDecider = RetryOptions['retryDecider'];
36+
3237
/**
3338
* Make REST API call with best-effort IAM auth.
3439
* @param amplify Amplify instance to to resolve credentials and tokens. Should use different instance in client-side
@@ -49,7 +54,15 @@ export const transferHandler = async (
4954
) => boolean,
5055
signingServiceInfo?: SigningServiceInfo,
5156
): Promise<RestApiResponse> => {
52-
const { url, method, headers, body, withCredentials, abortSignal } = options;
57+
const {
58+
url,
59+
method,
60+
headers,
61+
body,
62+
withCredentials,
63+
abortSignal,
64+
retryStrategy,
65+
} = options;
5366
const resolvedBody = body
5467
? body instanceof FormData
5568
? body
@@ -63,7 +76,9 @@ export const transferHandler = async (
6376
body: resolvedBody,
6477
};
6578
const baseOptions = {
66-
retryDecider: getRetryDecider(parseRestApiServiceError),
79+
retryDecider: getRetryDeciderFromStrategy(
80+
retryStrategy ?? amplify?.libraryOptions?.API?.REST?.retryStrategy,
81+
),
6782
computeDelay: jitteredBackoff,
6883
withCrossDomainCredentials: withCredentials,
6984
abortSignal,
@@ -99,6 +114,17 @@ export const transferHandler = async (
99114
};
100115
};
101116

117+
const getRetryDeciderFromStrategy = (
118+
retryStrategy: RetryStrategy | undefined,
119+
): RetryDecider => {
120+
const strategy = retryStrategy?.strategy;
121+
if (strategy === 'no-retry') {
122+
return () => Promise.resolve({ retryable: false });
123+
}
124+
125+
return getRetryDecider(parseRestApiServiceError);
126+
};
127+
102128
const resolveCredentials = async (
103129
amplify: AmplifyClassV6,
104130
): Promise<AWSCredentials | null> => {

0 commit comments

Comments
 (0)