From 96ad8154c89fead854bf0ea58575d58cc4059376 Mon Sep 17 00:00:00 2001 From: Zhang Date: Wed, 1 Oct 2025 14:26:40 +0200 Subject: [PATCH 1/2] feat(api-rest): support timeout configuration for rest api. --- .../__tests__/apis/common/publicApis.test.ts | 64 +++++++++++++ .../api-rest/src/apis/common/internalPost.ts | 36 ++++---- .../api-rest/src/apis/common/publicApis.ts | 92 +++++++++++-------- packages/api-rest/src/types/index.ts | 4 + .../src/utils/createCancellableOperation.ts | 78 +++++++++------- packages/core/src/singleton/API/types.ts | 4 + 6 files changed, 189 insertions(+), 89 deletions(-) diff --git a/packages/api-rest/__tests__/apis/common/publicApis.test.ts b/packages/api-rest/__tests__/apis/common/publicApis.test.ts index 8381fbf8406..3b1c7efd8c4 100644 --- a/packages/api-rest/__tests__/apis/common/publicApis.test.ts +++ b/packages/api-rest/__tests__/apis/common/publicApis.test.ts @@ -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(); diff --git a/packages/api-rest/src/apis/common/internalPost.ts b/packages/api-rest/src/apis/common/internalPost.ts index 93508913b0e..a724525a1ed 100644 --- a/packages/api-rest/src/apis/common/internalPost.ts +++ b/packages/api-rest/src/apis/common/internalPost.ts @@ -58,24 +58,28 @@ export const post = ( { url, options, abortController }: InternalPostInput, ): Promise => { 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); diff --git a/packages/api-rest/src/apis/common/publicApis.ts b/packages/api-rest/src/apis/common/publicApis.ts index d2eaa16e110..169db43d1ed 100644 --- a/packages/api-rest/src/apis/common/publicApis.ts +++ b/packages/api-rest/src/apis/common/publicApis.ts @@ -33,49 +33,63 @@ const publicHandler = ( amplify: AmplifyClassV6, options: ApiInput, 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'); diff --git a/packages/api-rest/src/types/index.ts b/packages/api-rest/src/types/index.ts index f011f717a94..99aa759b769 100644 --- a/packages/api-rest/src/types/index.ts +++ b/packages/api-rest/src/types/index.ts @@ -41,6 +41,10 @@ export interface RestApiOptionsBase { * @default ` { strategy: 'jittered-exponential-backoff' } ` */ retryStrategy?: RetryStrategy; + /** + * custom timeout in milliseconds. + */ + timeout?: number; } type Headers = Record; diff --git a/packages/api-rest/src/utils/createCancellableOperation.ts b/packages/api-rest/src/utils/createCancellableOperation.ts index f2ec4b5c714..b8eea218b53 100644 --- a/packages/api-rest/src/utils/createCancellableOperation.ts +++ b/packages/api-rest/src/utils/createCancellableOperation.ts @@ -16,6 +16,8 @@ import { logger } from './logger'; export function createCancellableOperation( handler: () => Promise, abortController: AbortController, + operationType: 'internal', + timeout?: number, ): Promise; /** @@ -23,37 +25,36 @@ export function createCancellableOperation( * @internal */ export function createCancellableOperation( - handler: (signal: AbortSignal) => Promise, + handler: () => Promise, + abortController: AbortController, + operationType: 'public', + timeout?: number, ): Operation; /** * @internal */ export function createCancellableOperation( - handler: - | ((signal: AbortSignal) => Promise) - | (() => Promise), - abortController?: AbortController, + handler: () => Promise, + abortController: AbortController, + operationType: 'public' | 'internal', + timeout?: number, ): Operation | Promise { - const isInternalPost = ( - targetHandler: - | ((signal: AbortSignal) => Promise) - | (() => Promise), - ): targetHandler is () => Promise => !!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)!; @@ -61,34 +62,43 @@ export function createCancellableOperation( 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 || 'Request was canceled', + 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; } }; diff --git a/packages/core/src/singleton/API/types.ts b/packages/core/src/singleton/API/types.ts index 1fceab2b5c3..3311a32072d 100644 --- a/packages/core/src/singleton/API/types.ts +++ b/packages/core/src/singleton/API/types.ts @@ -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; }; } From 1e06c4d5e5d9d92b678ce94b8ae6e8a920ba36fa Mon Sep 17 00:00:00 2001 From: Zhang Date: Wed, 1 Oct 2025 14:45:32 +0200 Subject: [PATCH 2/2] fix: restore cancel message --- packages/api-rest/src/utils/createCancellableOperation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-rest/src/utils/createCancellableOperation.ts b/packages/api-rest/src/utils/createCancellableOperation.ts index b8eea218b53..82937d41d08 100644 --- a/packages/api-rest/src/utils/createCancellableOperation.ts +++ b/packages/api-rest/src/utils/createCancellableOperation.ts @@ -74,7 +74,7 @@ export function createCancellableOperation( } else { const message = abortReason ?? abortSignal.reason; const canceledError = new CanceledError({ - message: message || 'Request was canceled', + ...(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.',