diff --git a/.changeset/few-oranges-tell.md b/.changeset/few-oranges-tell.md new file mode 100644 index 0000000000..d0ed9aebf0 --- /dev/null +++ b/.changeset/few-oranges-tell.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/ai-constructs': minor +'@aws-amplify/backend-ai': minor +--- + +Expose timeout property diff --git a/packages/ai-constructs/API.md b/packages/ai-constructs/API.md index 0f1550885f..85b12404d0 100644 --- a/packages/ai-constructs/API.md +++ b/packages/ai-constructs/API.md @@ -57,6 +57,7 @@ type ConversationHandlerFunctionProps = { region?: string; }>; memoryMB?: number; + timeoutSeconds?: number; logging?: { level?: ApplicationLogLevel; retention?: RetentionDays; diff --git a/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts b/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts index 284024a89e..fefa71aa53 100644 --- a/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts +++ b/packages/ai-constructs/src/conversation/conversation_handler_construct.test.ts @@ -287,6 +287,68 @@ void describe('Conversation Handler Function construct', () => { }); }); + void describe('timeout property', () => { + void it('sets valid timeout', () => { + const app = new App(); + const stack = new Stack(app); + new ConversationHandlerFunction(stack, 'conversationHandler', { + models: [], + timeoutSeconds: 124, + }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::Lambda::Function', { + Timeout: 124, + }); + }); + + void it('sets default timeout', () => { + const app = new App(); + const stack = new Stack(app); + new ConversationHandlerFunction(stack, 'conversationHandler', { + models: [], + }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::Lambda::Function', { + Timeout: 60, + }); + }); + + void it('throws on timeout below 1', () => { + assert.throws(() => { + const app = new App(); + const stack = new Stack(app); + new ConversationHandlerFunction(stack, 'conversationHandler', { + models: [], + timeoutSeconds: 0, + }); + }, new Error('timeoutSeconds must be a whole number between 1 and 900 inclusive')); + }); + + void it('throws on timeout above 15 minutes', () => { + assert.throws(() => { + const app = new App(); + const stack = new Stack(app); + new ConversationHandlerFunction(stack, 'conversationHandler', { + models: [], + timeoutSeconds: 60 * 15 + 1, + }); + }, new Error('timeoutSeconds must be a whole number between 1 and 900 inclusive')); + }); + + void it('throws on fractional memory', () => { + assert.throws(() => { + const app = new App(); + const stack = new Stack(app); + new ConversationHandlerFunction(stack, 'conversationHandler', { + models: [], + memoryMB: 256.2, + }); + }, new Error('memoryMB must be a whole number between 128 and 10240 inclusive')); + }); + }); + void describe('logging options', () => { void it('sets log level', () => { const app = new App(); diff --git a/packages/ai-constructs/src/conversation/conversation_handler_construct.ts b/packages/ai-constructs/src/conversation/conversation_handler_construct.ts index e5b563a6df..36c19c66d3 100644 --- a/packages/ai-constructs/src/conversation/conversation_handler_construct.ts +++ b/packages/ai-constructs/src/conversation/conversation_handler_construct.ts @@ -42,6 +42,13 @@ export type ConversationHandlerFunctionProps = { */ memoryMB?: number; + /** + * An amount of time in seconds between 1 second and 15 minutes. + * Must be a whole number. + * Default is 60 seconds. + */ + timeoutSeconds?: number; + logging?: { level?: ApplicationLogLevel; retention?: RetentionDays; @@ -96,7 +103,7 @@ export class ConversationHandlerFunction `conversationHandlerFunction`, { runtime: LambdaRuntime.NODEJS_18_X, - timeout: Duration.seconds(60), + timeout: Duration.seconds(this.resolveTimeout()), entry: this.props.entry ?? defaultHandlerFilePath, handler: 'handler', memorySize: this.resolveMemory(), @@ -185,6 +192,28 @@ export class ConversationHandlerFunction } return this.props.memoryMB; }; + + private resolveTimeout = () => { + const timeoutMin = 1; + const timeoutMax = 60 * 15; // 15 minutes in seconds + const timeoutDefault = 60; + if (this.props.timeoutSeconds === undefined) { + return timeoutDefault; + } + + if ( + !isWholeNumberBetweenInclusive( + this.props.timeoutSeconds, + timeoutMin, + timeoutMax + ) + ) { + throw new Error( + `timeoutSeconds must be a whole number between ${timeoutMin} and ${timeoutMax} inclusive` + ); + } + return this.props.timeoutSeconds; + }; } const isWholeNumberBetweenInclusive = ( diff --git a/packages/backend-ai/API.md b/packages/backend-ai/API.md index fcacb75c61..a5b3a31b1f 100644 --- a/packages/backend-ai/API.md +++ b/packages/backend-ai/API.md @@ -71,6 +71,7 @@ type DefineConversationHandlerFunctionProps = { region?: string; }>; memoryMB?: number; + timeoutSeconds?: number; logging?: ConversationHandlerFunctionLoggingOptions; }; diff --git a/packages/backend-ai/src/conversation/factory.test.ts b/packages/backend-ai/src/conversation/factory.test.ts index a264731ffb..94fc6fefe4 100644 --- a/packages/backend-ai/src/conversation/factory.test.ts +++ b/packages/backend-ai/src/conversation/factory.test.ts @@ -16,6 +16,7 @@ import { customEntryHandler } from './test-assets/with-custom-entry/resource.js' import { Template } from 'aws-cdk-lib/assertions'; import { defineConversationHandlerFunction } from './factory.js'; import { ConversationHandlerFunction } from '@aws-amplify/ai-constructs/conversation'; +import { AmplifyError } from '@aws-amplify/platform-core'; const createStackAndSetContext = (): Stack => { const app = new App(); @@ -204,6 +205,55 @@ void describe('ConversationHandlerFactory', () => { }); }); + void it('maps invalid memory error', () => { + const factory = defineConversationHandlerFunction({ + entry: './test-assets/with-default-entry/handler.ts', + name: 'testHandlerName', + models: [], + memoryMB: -1, + }); + assert.throws( + () => factory.getInstance(getInstanceProps), + (error: Error) => { + assert.ok(AmplifyError.isAmplifyError(error)); + assert.strictEqual(error.name, 'InvalidMemoryMBError'); + return true; + } + ); + }); + + void it('passes timeout setting to construct', () => { + const factory = defineConversationHandlerFunction({ + entry: './test-assets/with-default-entry/handler.ts', + name: 'testHandlerName', + models: [], + timeoutSeconds: 124, + }); + const lambda = factory.getInstance(getInstanceProps); + const template = Template.fromStack(Stack.of(lambda.resources.lambda)); + template.resourceCountIs('AWS::Lambda::Function', 1); + template.hasResourceProperties('AWS::Lambda::Function', { + Timeout: 124, + }); + }); + + void it('maps invalid timeout error', () => { + const factory = defineConversationHandlerFunction({ + entry: './test-assets/with-default-entry/handler.ts', + name: 'testHandlerName', + models: [], + timeoutSeconds: -1, + }); + assert.throws( + () => factory.getInstance(getInstanceProps), + (error: Error) => { + assert.ok(AmplifyError.isAmplifyError(error)); + assert.strictEqual(error.name, 'InvalidTimeoutError'); + return true; + } + ); + }); + void it('passes log level to construct', () => { const factory = defineConversationHandlerFunction({ entry: './test-assets/with-default-entry/handler.ts', diff --git a/packages/backend-ai/src/conversation/factory.ts b/packages/backend-ai/src/conversation/factory.ts index d5a88c3f03..fb488a8ac5 100644 --- a/packages/backend-ai/src/conversation/factory.ts +++ b/packages/backend-ai/src/conversation/factory.ts @@ -17,7 +17,10 @@ import { ConversationTurnEventVersion, } from '@aws-amplify/ai-constructs/conversation'; import path from 'path'; -import { CallerDirectoryExtractor } from '@aws-amplify/platform-core'; +import { + AmplifyUserError, + CallerDirectoryExtractor, +} from '@aws-amplify/platform-core'; import { AiModel } from '@aws-amplify/data-schema-types'; import { LogLevelConverter, @@ -52,6 +55,7 @@ class ConversationHandlerFunctionGenerator }), outputStorageStrategy: this.outputStorageStrategy, memoryMB: this.props.memoryMB, + timeoutSeconds: this.props.timeoutSeconds, }; const logging: typeof constructProps.logging = {}; if (this.props.logging?.level) { @@ -65,12 +69,34 @@ class ConversationHandlerFunctionGenerator ); } constructProps.logging = logging; - const conversationHandlerFunction = new ConversationHandlerFunction( - scope, - this.props.name, - constructProps - ); - return conversationHandlerFunction; + try { + return new ConversationHandlerFunction( + scope, + this.props.name, + constructProps + ); + } catch (e) { + throw this.mapConstructErrors(e); + } + }; + + private mapConstructErrors = (e: unknown) => { + if (!(e instanceof Error)) { + return e; + } + if (e.message.startsWith('memoryMB must be')) { + return new AmplifyUserError('InvalidMemoryMBError', { + message: `Invalid memoryMB of ${this.props.memoryMB}`, + resolution: e.message, + }); + } + if (e.message.startsWith('timeoutSeconds must be')) { + return new AmplifyUserError('InvalidTimeoutError', { + message: `Invalid timeout of ${this.props.timeoutSeconds} seconds`, + resolution: e.message, + }); + } + return e; }; } @@ -155,6 +181,12 @@ export type DefineConversationHandlerFunctionProps = { * Default is 512MB. */ memoryMB?: number; + /** + * An amount of time in seconds between 1 second and 15 minutes. + * Must be a whole number. + * Default is 60 seconds. + */ + timeoutSeconds?: number; logging?: ConversationHandlerFunctionLoggingOptions; };