Skip to content

Commit 0cf2160

Browse files
authored
feat(api-rest): support timeout configuration for rest api. (#14578)
1 parent db51f91 commit 0cf2160

File tree

6 files changed

+189
-89
lines changed

6 files changed

+189
-89
lines changed

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,70 @@ describe('public APIs', () => {
450450
}
451451
});
452452

453+
it('should support timeout configuration at request level', async () => {
454+
expect.assertions(3);
455+
const timeoutSpy = jest.spyOn(global, 'setTimeout');
456+
mockAuthenticatedHandler.mockImplementation(() => {
457+
return new Promise((_resolve, reject) => {
458+
setTimeout(() => {
459+
const abortError = new Error('AbortError');
460+
abortError.name = 'AbortError';
461+
reject(abortError);
462+
}, 300);
463+
});
464+
});
465+
try {
466+
await fn(mockAmplifyInstance, {
467+
apiName: 'restApi1',
468+
path: '/items',
469+
options: {
470+
timeout: 100,
471+
},
472+
}).response;
473+
} catch (error: any) {
474+
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100);
475+
expect(error.name).toBe('TimeoutError');
476+
expect(error.message).toBe('Request timeout after 100ms');
477+
timeoutSpy.mockRestore();
478+
}
479+
});
480+
481+
it('should support timeout configuration at library options level', async () => {
482+
expect.assertions(3);
483+
const timeoutSpy = jest.spyOn(global, 'setTimeout');
484+
const mockTimeoutFunction = jest.fn().mockReturnValue(100);
485+
const mockAmplifyInstanceWithTimeout = {
486+
...mockAmplifyInstance,
487+
libraryOptions: {
488+
API: {
489+
REST: {
490+
timeout: mockTimeoutFunction,
491+
},
492+
},
493+
},
494+
} as any as AmplifyClassV6;
495+
mockAuthenticatedHandler.mockImplementation(() => {
496+
return new Promise((_resolve, reject) => {
497+
setTimeout(() => {
498+
const abortError = new Error('AbortError');
499+
abortError.name = 'AbortError';
500+
reject(abortError);
501+
}, 300);
502+
});
503+
});
504+
try {
505+
await fn(mockAmplifyInstanceWithTimeout, {
506+
apiName: 'restApi1',
507+
path: '/items',
508+
}).response;
509+
} catch (error: any) {
510+
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100);
511+
expect(error.name).toBe('TimeoutError');
512+
expect(error.message).toBe('Request timeout after 100ms');
513+
timeoutSpy.mockRestore();
514+
}
515+
});
516+
453517
describe('retry strategy', () => {
454518
beforeEach(() => {
455519
mockAuthenticatedHandler.mockReset();

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

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,28 @@ export const post = (
5858
{ url, options, abortController }: InternalPostInput,
5959
): Promise<RestApiResponse> => {
6060
const controller = abortController ?? new AbortController();
61-
const responsePromise = createCancellableOperation(async () => {
62-
const response = transferHandler(
63-
amplify,
64-
{
65-
url,
66-
method: 'POST',
67-
...options,
68-
abortSignal: controller.signal,
69-
retryStrategy: {
70-
strategy: 'jittered-exponential-backoff',
61+
const responsePromise = createCancellableOperation(
62+
async () => {
63+
const response = transferHandler(
64+
amplify,
65+
{
66+
url,
67+
method: 'POST',
68+
...options,
69+
abortSignal: controller.signal,
70+
retryStrategy: {
71+
strategy: 'jittered-exponential-backoff',
72+
},
7173
},
72-
},
73-
isIamAuthApplicableForGraphQL,
74-
options?.signingServiceInfo,
75-
);
74+
isIamAuthApplicableForGraphQL,
75+
options?.signingServiceInfo,
76+
);
7677

77-
return response;
78-
}, controller);
78+
return response;
79+
},
80+
controller,
81+
'internal', // operation Type
82+
);
7983

8084
const responseWithCleanUp = responsePromise.finally(() => {
8185
cancelTokenMap.delete(responseWithCleanUp);

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

Lines changed: 53 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,49 +33,63 @@ const publicHandler = (
3333
amplify: AmplifyClassV6,
3434
options: ApiInput<RestApiOptionsBase>,
3535
method: string,
36-
) =>
37-
createCancellableOperation(async abortSignal => {
38-
const { apiName, options: apiOptions = {}, path: apiPath } = options;
39-
const url = resolveApiUrl(
40-
amplify,
41-
apiName,
42-
apiPath,
43-
apiOptions?.queryParams,
44-
);
45-
const libraryConfigHeaders =
46-
await amplify.libraryOptions?.API?.REST?.headers?.({
36+
) => {
37+
const { apiName, options: apiOptions = {}, path: apiPath } = options;
38+
const libraryConfigTimeout = amplify.libraryOptions?.API?.REST?.timeout?.({
39+
apiName,
40+
method,
41+
});
42+
const timeout = apiOptions?.timeout || libraryConfigTimeout || undefined;
43+
const publicApisAbortController = new AbortController();
44+
const abortSignal = publicApisAbortController.signal;
45+
46+
return createCancellableOperation(
47+
async () => {
48+
const url = resolveApiUrl(
49+
amplify,
50+
apiName,
51+
apiPath,
52+
apiOptions?.queryParams,
53+
);
54+
const libraryConfigHeaders =
55+
await amplify.libraryOptions?.API?.REST?.headers?.({
56+
apiName,
57+
});
58+
const { headers: invocationHeaders = {} } = apiOptions;
59+
const headers = {
60+
// custom headers from invocation options should precede library options
61+
...libraryConfigHeaders,
62+
...invocationHeaders,
63+
};
64+
const signingServiceInfo = parseSigningInfo(url, {
65+
amplify,
4766
apiName,
4867
});
49-
const { headers: invocationHeaders = {} } = apiOptions;
50-
const headers = {
51-
// custom headers from invocation options should precede library options
52-
...libraryConfigHeaders,
53-
...invocationHeaders,
54-
};
55-
const signingServiceInfo = parseSigningInfo(url, {
56-
amplify,
57-
apiName,
58-
});
59-
logger.debug(
60-
method,
61-
url,
62-
headers,
63-
`IAM signing options: ${JSON.stringify(signingServiceInfo)}`,
64-
);
65-
66-
return transferHandler(
67-
amplify,
68-
{
69-
...apiOptions,
70-
url,
68+
logger.debug(
7169
method,
70+
url,
7271
headers,
73-
abortSignal,
74-
},
75-
isIamAuthApplicableForRest,
76-
signingServiceInfo,
77-
);
78-
});
72+
`IAM signing options: ${JSON.stringify(signingServiceInfo)}`,
73+
);
74+
75+
return transferHandler(
76+
amplify,
77+
{
78+
...apiOptions,
79+
url,
80+
method,
81+
headers,
82+
abortSignal,
83+
},
84+
isIamAuthApplicableForRest,
85+
signingServiceInfo,
86+
);
87+
},
88+
publicApisAbortController,
89+
'public', // operation Type
90+
timeout,
91+
);
92+
};
7993

8094
export const get = (amplify: AmplifyClassV6, input: GetInput): GetOperation =>
8195
publicHandler(amplify, input, 'GET');

packages/api-rest/src/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export interface RestApiOptionsBase {
4141
* @default ` { strategy: 'jittered-exponential-backoff' } `
4242
*/
4343
retryStrategy?: RetryStrategy;
44+
/**
45+
* custom timeout in milliseconds.
46+
*/
47+
timeout?: number;
4448
}
4549

4650
type Headers = Record<string, string>;

packages/api-rest/src/utils/createCancellableOperation.ts

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,79 +16,89 @@ import { logger } from './logger';
1616
export function createCancellableOperation(
1717
handler: () => Promise<HttpResponse>,
1818
abortController: AbortController,
19+
operationType: 'internal',
20+
timeout?: number,
1921
): Promise<HttpResponse>;
2022

2123
/**
2224
* Create a cancellable operation conforming to the external REST API interface.
2325
* @internal
2426
*/
2527
export function createCancellableOperation(
26-
handler: (signal: AbortSignal) => Promise<HttpResponse>,
28+
handler: () => Promise<HttpResponse>,
29+
abortController: AbortController,
30+
operationType: 'public',
31+
timeout?: number,
2732
): Operation<HttpResponse>;
2833

2934
/**
3035
* @internal
3136
*/
3237
export function createCancellableOperation(
33-
handler:
34-
| ((signal: AbortSignal) => Promise<HttpResponse>)
35-
| (() => Promise<HttpResponse>),
36-
abortController?: AbortController,
38+
handler: () => Promise<HttpResponse>,
39+
abortController: AbortController,
40+
operationType: 'public' | 'internal',
41+
timeout?: number,
3742
): Operation<HttpResponse> | Promise<HttpResponse> {
38-
const isInternalPost = (
39-
targetHandler:
40-
| ((signal: AbortSignal) => Promise<HttpResponse>)
41-
| (() => Promise<HttpResponse>),
42-
): targetHandler is () => Promise<HttpResponse> => !!abortController;
43-
44-
// For creating a cancellable operation for public REST APIs, we need to create an AbortController
45-
// internally. Whereas for internal POST APIs, we need to accept in the AbortController from the
46-
// callers.
47-
const publicApisAbortController = new AbortController();
48-
const publicApisAbortSignal = publicApisAbortController.signal;
49-
const internalPostAbortSignal = abortController?.signal;
43+
const abortSignal = abortController.signal;
5044
let abortReason: string;
45+
if (timeout != null) {
46+
if (timeout < 0) {
47+
throw new Error('Timeout must be a non-negative number');
48+
}
49+
setTimeout(() => {
50+
abortReason = 'TimeoutError';
51+
abortController.abort(abortReason);
52+
}, timeout);
53+
}
5154

5255
const job = async () => {
5356
try {
54-
const response = await (isInternalPost(handler)
55-
? handler()
56-
: handler(publicApisAbortSignal));
57+
const response = await handler();
5758

5859
if (response.statusCode >= 300) {
5960
throw await parseRestApiServiceError(response)!;
6061
}
6162

6263
return response;
6364
} catch (error: any) {
64-
const abortSignal = internalPostAbortSignal ?? publicApisAbortSignal;
65-
const message = abortReason ?? abortSignal.reason;
6665
if (error.name === 'AbortError' || abortSignal?.aborted === true) {
67-
const canceledError = new CanceledError({
68-
...(message && { message }),
69-
underlyingError: error,
70-
recoverySuggestion:
71-
'The API request was explicitly canceled. If this is not intended, validate if you called the `cancel()` function on the API request erroneously.',
72-
});
73-
logger.debug(error);
74-
throw canceledError;
66+
// Check if timeout caused the abort
67+
const isTimeout = abortReason && abortReason === 'TimeoutError';
68+
69+
if (isTimeout) {
70+
const timeoutError = new Error(`Request timeout after ${timeout}ms`);
71+
timeoutError.name = 'TimeoutError';
72+
logger.debug(timeoutError);
73+
throw timeoutError;
74+
} else {
75+
const message = abortReason ?? abortSignal.reason;
76+
const canceledError = new CanceledError({
77+
...(message && { message }),
78+
underlyingError: error,
79+
recoverySuggestion:
80+
'The API request was explicitly canceled. If this is not intended, validate if you called the `cancel()` function on the API request erroneously.',
81+
});
82+
logger.debug(canceledError);
83+
throw canceledError;
84+
}
7585
}
7686
logger.debug(error);
7787
throw error;
7888
}
7989
};
8090

81-
if (isInternalPost(handler)) {
91+
if (operationType === 'internal') {
8292
return job();
8393
} else {
8494
const cancel = (abortMessage?: string) => {
85-
if (publicApisAbortSignal.aborted === true) {
95+
if (abortSignal.aborted === true) {
8696
return;
8797
}
88-
publicApisAbortController.abort(abortMessage);
98+
abortController.abort(abortMessage);
8999
// If abort reason is not supported, set a scoped reasons instead. The reason property inside an
90100
// AbortSignal is a readonly property and trying to set it would throw an error.
91-
if (abortMessage && publicApisAbortSignal.reason !== abortMessage) {
101+
if (abortMessage && abortSignal.reason !== abortMessage) {
92102
abortReason = abortMessage;
93103
}
94104
};

packages/core/src/singleton/API/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export interface LibraryAPIOptions {
2525
* @default ` { strategy: 'jittered-exponential-backoff' } `
2626
*/
2727
retryStrategy?: RetryStrategy;
28+
/**
29+
* custom timeout in milliseconds configurable for given REST service, or/and method.
30+
*/
31+
timeout?(options: { apiName: string; method: string }): number;
2832
};
2933
}
3034

0 commit comments

Comments
 (0)