Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/api-rest/__tests__/apis/common/publicApis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,70 @@ describe('public APIs', () => {
}
});

it('should support timeout configuration at request level', async () => {
expect.assertions(3);
const timeoutSpy = jest.spyOn(global, 'setTimeout');
mockAuthenticatedHandler.mockImplementation(() => {
return new Promise((_resolve, reject) => {
setTimeout(() => {
const abortError = new Error('AbortError');
abortError.name = 'AbortError';
reject(abortError);
}, 300);
});
});
try {
await fn(mockAmplifyInstance, {
apiName: 'restApi1',
path: '/items',
options: {
timeout: 100,
},
}).response;
} catch (error: any) {
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100);
expect(error.name).toBe('TimeoutError');
expect(error.message).toBe('Request timeout after 100ms');
timeoutSpy.mockRestore();
}
});

it('should support timeout configuration at library options level', async () => {
expect.assertions(3);
const timeoutSpy = jest.spyOn(global, 'setTimeout');
const mockTimeoutFunction = jest.fn().mockReturnValue(100);
const mockAmplifyInstanceWithTimeout = {
...mockAmplifyInstance,
libraryOptions: {
API: {
REST: {
timeout: mockTimeoutFunction,
},
},
},
} as any as AmplifyClassV6;
mockAuthenticatedHandler.mockImplementation(() => {
return new Promise((_resolve, reject) => {
setTimeout(() => {
const abortError = new Error('AbortError');
abortError.name = 'AbortError';
reject(abortError);
}, 300);
});
});
try {
await fn(mockAmplifyInstanceWithTimeout, {
apiName: 'restApi1',
path: '/items',
}).response;
} catch (error: any) {
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100);
expect(error.name).toBe('TimeoutError');
expect(error.message).toBe('Request timeout after 100ms');
timeoutSpy.mockRestore();
}
});

describe('retry strategy', () => {
beforeEach(() => {
mockAuthenticatedHandler.mockReset();
Expand Down
36 changes: 20 additions & 16 deletions packages/api-rest/src/apis/common/internalPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
* @param postInput.abortController The abort controller used to cancel the POST request
* @returns a {@link RestApiResponse}
*
* @throws an {@link AmplifyError} with `Network Error` as the `message` when the external resource is unreachable due to one

Check warning on line 50 in packages/api-rest/src/apis/common/internalPost.ts

View workflow job for this annotation

GitHub Actions / unit-tests / Unit Test - @aws-amplify/api-rest

The type 'AmplifyError' is undefined
* of the following reasons:
* 1. no network connection
* 2. CORS error
Expand All @@ -58,24 +58,28 @@
{ url, options, abortController }: InternalPostInput,
): Promise<RestApiResponse> => {
const controller = abortController ?? new AbortController();
const responsePromise = createCancellableOperation(async () => {
const response = transferHandler(
amplify,
{
url,
method: 'POST',
...options,
abortSignal: controller.signal,
retryStrategy: {
strategy: 'jittered-exponential-backoff',
const responsePromise = createCancellableOperation(
async () => {
const response = transferHandler(
amplify,
{
url,
method: 'POST',
...options,
abortSignal: controller.signal,
retryStrategy: {
strategy: 'jittered-exponential-backoff',
},
},
},
isIamAuthApplicableForGraphQL,
options?.signingServiceInfo,
);
isIamAuthApplicableForGraphQL,
options?.signingServiceInfo,
);

return response;
}, controller);
return response;
},
controller,
'internal', // operation Type
);

const responseWithCleanUp = responsePromise.finally(() => {
cancelTokenMap.delete(responseWithCleanUp);
Expand Down
92 changes: 53 additions & 39 deletions packages/api-rest/src/apis/common/publicApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,49 +33,63 @@ const publicHandler = (
amplify: AmplifyClassV6,
options: ApiInput<RestApiOptionsBase>,
method: string,
) =>
createCancellableOperation(async abortSignal => {
const { apiName, options: apiOptions = {}, path: apiPath } = options;
const url = resolveApiUrl(
amplify,
apiName,
apiPath,
apiOptions?.queryParams,
);
const libraryConfigHeaders =
await amplify.libraryOptions?.API?.REST?.headers?.({
) => {
const { apiName, options: apiOptions = {}, path: apiPath } = options;
const libraryConfigTimeout = amplify.libraryOptions?.API?.REST?.timeout?.({
apiName,
method,
});
const timeout = apiOptions?.timeout || libraryConfigTimeout || undefined;
const publicApisAbortController = new AbortController();
const abortSignal = publicApisAbortController.signal;

return createCancellableOperation(
async () => {
const url = resolveApiUrl(
amplify,
apiName,
apiPath,
apiOptions?.queryParams,
);
const libraryConfigHeaders =
await amplify.libraryOptions?.API?.REST?.headers?.({
apiName,
});
const { headers: invocationHeaders = {} } = apiOptions;
const headers = {
// custom headers from invocation options should precede library options
...libraryConfigHeaders,
...invocationHeaders,
};
const signingServiceInfo = parseSigningInfo(url, {
amplify,
apiName,
});
const { headers: invocationHeaders = {} } = apiOptions;
const headers = {
// custom headers from invocation options should precede library options
...libraryConfigHeaders,
...invocationHeaders,
};
const signingServiceInfo = parseSigningInfo(url, {
amplify,
apiName,
});
logger.debug(
method,
url,
headers,
`IAM signing options: ${JSON.stringify(signingServiceInfo)}`,
);

return transferHandler(
amplify,
{
...apiOptions,
url,
logger.debug(
method,
url,
headers,
abortSignal,
},
isIamAuthApplicableForRest,
signingServiceInfo,
);
});
`IAM signing options: ${JSON.stringify(signingServiceInfo)}`,
);

return transferHandler(
amplify,
{
...apiOptions,
url,
method,
headers,
abortSignal,
},
isIamAuthApplicableForRest,
signingServiceInfo,
);
},
publicApisAbortController,
'public', // operation Type
timeout,
);
};

export const get = (amplify: AmplifyClassV6, input: GetInput): GetOperation =>
publicHandler(amplify, input, 'GET');
Expand Down
4 changes: 4 additions & 0 deletions packages/api-rest/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export interface RestApiOptionsBase {
* @default ` { strategy: 'jittered-exponential-backoff' } `
*/
retryStrategy?: RetryStrategy;
/**
* custom timeout in milliseconds.
*/
timeout?: number;
}

type Headers = Record<string, string>;
Expand Down
78 changes: 44 additions & 34 deletions packages/api-rest/src/utils/createCancellableOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,79 +16,89 @@ import { logger } from './logger';
export function createCancellableOperation(
handler: () => Promise<HttpResponse>,
abortController: AbortController,
operationType: 'internal',
timeout?: number,
): Promise<HttpResponse>;

/**
* Create a cancellable operation conforming to the external REST API interface.
* @internal
*/
export function createCancellableOperation(
handler: (signal: AbortSignal) => Promise<HttpResponse>,
handler: () => Promise<HttpResponse>,
abortController: AbortController,
operationType: 'public',
timeout?: number,
): Operation<HttpResponse>;

/**
* @internal
*/
export function createCancellableOperation(
handler:
| ((signal: AbortSignal) => Promise<HttpResponse>)
| (() => Promise<HttpResponse>),
abortController?: AbortController,
handler: () => Promise<HttpResponse>,
abortController: AbortController,
operationType: 'public' | 'internal',
timeout?: number,
): Operation<HttpResponse> | Promise<HttpResponse> {
const isInternalPost = (
targetHandler:
| ((signal: AbortSignal) => Promise<HttpResponse>)
| (() => Promise<HttpResponse>),
): targetHandler is () => Promise<HttpResponse> => !!abortController;

// For creating a cancellable operation for public REST APIs, we need to create an AbortController
// internally. Whereas for internal POST APIs, we need to accept in the AbortController from the
// callers.
const publicApisAbortController = new AbortController();
const publicApisAbortSignal = publicApisAbortController.signal;
const internalPostAbortSignal = abortController?.signal;
const abortSignal = abortController.signal;
let abortReason: string;
if (timeout != null) {
if (timeout < 0) {
throw new Error('Timeout must be a non-negative number');
}
setTimeout(() => {
abortReason = 'TimeoutError';
abortController.abort(abortReason);
}, timeout);
}

const job = async () => {
try {
const response = await (isInternalPost(handler)
? handler()
: handler(publicApisAbortSignal));
const response = await handler();

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

return response;
} catch (error: any) {
const abortSignal = internalPostAbortSignal ?? publicApisAbortSignal;
const message = abortReason ?? abortSignal.reason;
if (error.name === 'AbortError' || abortSignal?.aborted === true) {
const canceledError = new CanceledError({
...(message && { message }),
underlyingError: error,
recoverySuggestion:
'The API request was explicitly canceled. If this is not intended, validate if you called the `cancel()` function on the API request erroneously.',
});
logger.debug(error);
throw canceledError;
// Check if timeout caused the abort
const isTimeout = abortReason && abortReason === 'TimeoutError';

if (isTimeout) {
const timeoutError = new Error(`Request timeout after ${timeout}ms`);
timeoutError.name = 'TimeoutError';
logger.debug(timeoutError);
throw timeoutError;
} else {
const message = abortReason ?? abortSignal.reason;
const canceledError = new CanceledError({
...(message && { message }),
underlyingError: error,
recoverySuggestion:
'The API request was explicitly canceled. If this is not intended, validate if you called the `cancel()` function on the API request erroneously.',
});
logger.debug(canceledError);
throw canceledError;
}
}
logger.debug(error);
throw error;
}
};

if (isInternalPost(handler)) {
if (operationType === 'internal') {
return job();
} else {
const cancel = (abortMessage?: string) => {
if (publicApisAbortSignal.aborted === true) {
if (abortSignal.aborted === true) {
return;
}
publicApisAbortController.abort(abortMessage);
abortController.abort(abortMessage);
// If abort reason is not supported, set a scoped reasons instead. The reason property inside an
// AbortSignal is a readonly property and trying to set it would throw an error.
if (abortMessage && publicApisAbortSignal.reason !== abortMessage) {
if (abortMessage && abortSignal.reason !== abortMessage) {
abortReason = abortMessage;
}
};
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/singleton/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export interface LibraryAPIOptions {
* @default ` { strategy: 'jittered-exponential-backoff' } `
*/
retryStrategy?: RetryStrategy;
/**
* custom timeout in milliseconds configurable for given REST service, or/and method.
*/
timeout?(options: { apiName: string; method: string }): number;
};
}

Expand Down
Loading