Skip to content
Merged
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,23 @@ stepFunctions:
...
```

### Auto-configure Task timeouts

Lambda function timeouts are not surfaced as `States.Timeout` errors in Step Functions; they appear as `States.TaskFailed`. To bridge the gap you can set `TimeoutSeconds` on every Task state, but it's easy to forget and tedious to keep in sync with each function's `timeout`.

Set `configureTaskTimeouts: true` on a state machine to automatically inject `TimeoutSeconds` into Task states that invoke a Lambda function defined in the same Serverless service. The injected value is the function's configured `timeout`, falling back to the service-wide `provider.timeout`, and finally to the Serverless Framework default of 6 seconds. This works for both the legacy direct-invoke form (`Resource: !GetAtt fn.Arn`) and the service-integration form (`Resource: arn:aws:states:::lambda:invoke`), and recurses into `Parallel` branches and `Map` iterators.

```yaml
stepFunctions:
stateMachines:
hellostepfunc1:
configureTaskTimeouts: true
definition:
...
```

If a Task state already declares `TimeoutSeconds` or `TimeoutSecondsPath`, the existing value is preserved. If the user-set `TimeoutSeconds` is strictly greater than the Lambda's timeout, a warning is logged at deploy time — the Lambda will fail before the state-level timeout fires, so the longer value has no effect.

### Pre-deployment validation

By default, your state machine definition will be validated during deployment by StepFunctions. This can be cumbersome when developing because you have to upload your service for every typo in your definition. In order to go faster, you can enable pre-deployment validation using [asl-validator](https://www.npmjs.com/package/asl-validator) which should detect most of the issues (like a missing state property).
Expand Down
3 changes: 3 additions & 0 deletions fixtures/configure-task-timeouts/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use strict';

module.exports.fn = async () => ({ statusCode: 200 });
65 changes: 65 additions & 0 deletions fixtures/configure-task-timeouts/serverless.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
service: integration-configure-task-timeouts

provider: ${file(../base.yml):provider}
plugins: ${file(../base.yml):plugins}
package: ${file(../base.yml):package}
custom: ${file(../base.yml):custom}

functions:
short:
handler: handler.fn
timeout: 12
long:
handler: handler.fn
timeout: 90
defaulted:
handler: handler.fn

stepFunctions:
stateMachines:
autoTimeoutsMachine:
name: integration-configure-task-timeouts-${opt:stage, 'test'}
configureTaskTimeouts: true
definition:
StartAt: DirectInvoke
States:
DirectInvoke:
Type: Task
Resource:
Fn::GetAtt: [ShortLambdaFunction, Arn]
Next: ServiceIntegration
ServiceIntegration:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName:
Fn::GetAtt: [LongLambdaFunction, Arn]
Payload.$: $
Next: UserSetTimeout
UserSetTimeout:
Type: Task
Resource:
Fn::GetAtt: [ShortLambdaFunction, Arn]
TimeoutSeconds: 5
Next: ParallelStep
ParallelStep:
Type: Parallel
End: true
Branches:
- StartAt: NestedTask
States:
NestedTask:
Type: Task
Resource:
Fn::GetAtt: [DefaultedLambdaFunction, Arn]
End: true
backCompatMachine:
name: integration-back-compat-task-timeouts-${opt:stage, 'test'}
definition:
StartAt: NoInjection
States:
NoInjection:
Type: Task
Resource:
Fn::GetAtt: [ShortLambdaFunction, Arn]
End: true
68 changes: 68 additions & 0 deletions fixtures/configure-task-timeouts/verify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');
const expect = require('chai').expect;

const templatePath = path.join(__dirname, '.serverless', 'cloudformation-template-update-stack.json');

const findStateMachine = (resources, namePrefix) => {
const entry = Object.values(resources).find(
(r) => r.Type === 'AWS::StepFunctions::StateMachine'
&& typeof r.Properties.StateMachineName === 'string'
&& r.Properties.StateMachineName.startsWith(namePrefix),
);
return entry || null;
};

const parseDefinition = (stateMachine) => {
const ds = stateMachine.Properties.DefinitionString;
if (typeof ds === 'string') return JSON.parse(ds);
const sub = ds['Fn::Sub'];
return JSON.parse(Array.isArray(sub) ? sub[0] : sub);
};

describe('configure-task-timeouts fixture — CloudFormation template', () => {
let resources;

before(() => {
const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
resources = template.Resources;
});

describe('autoTimeoutsMachine (configureTaskTimeouts: true)', () => {
let definition;

before(() => {
const sm = findStateMachine(resources, 'integration-configure-task-timeouts-');
expect(sm, 'auto-timeouts state machine should exist').to.not.equal(null);
definition = parseDefinition(sm);
});

it('injects TimeoutSeconds for legacy direct invoke (Resource: Fn::GetAtt) using function.timeout', () => {
expect(definition.States.DirectInvoke.TimeoutSeconds).to.equal(12);
});

it('injects TimeoutSeconds for service-integration lambda:invoke using function.timeout', () => {
expect(definition.States.ServiceIntegration.TimeoutSeconds).to.equal(90);
});

it('preserves a user-set TimeoutSeconds and does not overwrite it', () => {
expect(definition.States.UserSetTimeout.TimeoutSeconds).to.equal(5);
});

it('falls back to the Serverless Framework default (6s) when the function has no timeout configured', () => {
const nested = definition.States.ParallelStep.Branches[0].States.NestedTask;
expect(nested.TimeoutSeconds).to.equal(6);
});
});

describe('backCompatMachine (configureTaskTimeouts unset)', () => {
it('does not inject TimeoutSeconds when the flag is off', () => {
const sm = findStateMachine(resources, 'integration-back-compat-task-timeouts-');
expect(sm, 'back-compat state machine should exist').to.not.equal(null);
const definition = parseDefinition(sm);
expect(definition.States.NoInjection.TimeoutSeconds).to.equal(undefined);
});
});
});
3 changes: 1 addition & 2 deletions fixtures/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions lib/deploy/stepFunctions/compileStateMachines.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const aslValidator = require('asl-validator');
const BbPromise = require('bluebird');
const crypto = require('node:crypto');
const schema = require('./compileStateMachines.schema');
const configureTaskTimeouts = require('./configureTaskTimeouts');
const {
isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion, resolveLambdaFunctionName,
} = require('../../utils/aws');
Expand Down Expand Up @@ -135,6 +136,15 @@ module.exports = {
DefinitionString = JSON.stringify(stateMachineObj.definition)
.replace(/\\n|\\r|\\n\\r/g, '');
} else {
if (stateMachineObj.configureTaskTimeouts === true) {
configureTaskTimeouts({
definition: stateMachineObj.definition,
functions: this.serverless.service.functions,
providerTimeout: this.serverless.service.provider.timeout,
getLambdaLogicalId: (key) => this.provider.naming.getLambdaLogicalId(key),
stateMachineName,
});
}
const functionMappings = Array.from(getIntrinsicFunctions(stateMachineObj.definition));
const { replaced, definition } = replacePseudoParameters(stateMachineObj.definition);
const definitionString = JSON.stringify(definition, undefined, 2);
Expand Down
2 changes: 2 additions & 0 deletions lib/deploy/stepFunctions/compileStateMachines.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const events = Joi.array();
const alarms = Joi.object();
const notifications = Joi.object();
const useExactVersion = Joi.boolean().default(false);
const configureTaskTimeouts = Joi.boolean().default(false);
const type = Joi.string().valid('STANDARD', 'EXPRESS').default('STANDARD');
const retain = Joi.boolean().default(false);

Expand All @@ -81,6 +82,7 @@ const schema = Joi.object().keys({
name,
role: arn,
useExactVersion,
configureTaskTimeouts,
definition: definition.required(),
dependsOn,
tags,
Expand Down
54 changes: 54 additions & 0 deletions lib/deploy/stepFunctions/compileStateMachines.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2239,4 +2239,58 @@ describe('#compileStateMachines', () => {
expect(result).to.have.property('then').that.is.a('function');
return result;
});

describe('configureTaskTimeouts', () => {
beforeEach(() => {
serverless.service.functions = {
hello: { handler: 'h.fn', timeout: 30 },
};
});

const buildSm = (extra) => ({
stateMachines: {
myStateMachine1: {
id: 'Test',
...extra,
definition: {
StartAt: 'Hello',
States: {
Hello: {
Type: 'Task',
Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] },
End: true,
},
},
},
},
},
});

const compileAndGetState = () => {
serverlessStepFunctions.compileStateMachines();
const stateMachine = serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources.Test;
const subContent = stateMachine.Properties.DefinitionString['Fn::Sub'];
const definitionJson = Array.isArray(subContent) ? subContent[0] : subContent;
const parsed = JSON.parse(definitionJson);
return parsed.States.Hello;
};

it('does not inject TimeoutSeconds when configureTaskTimeouts is not set', () => {
serverless.service.stepFunctions = buildSm();
const hello = compileAndGetState();
expect(hello.TimeoutSeconds).to.equal(undefined);
});

it('injects TimeoutSeconds from the lambda config when configureTaskTimeouts is true', () => {
serverless.service.stepFunctions = buildSm({ configureTaskTimeouts: true });
const hello = compileAndGetState();
expect(hello.TimeoutSeconds).to.equal(30);
});

it('rejects unknown configureTaskTimeouts values via schema', () => {
serverless.service.stepFunctions = buildSm({ configureTaskTimeouts: 'yes' });
expect(() => serverlessStepFunctions.compileStateMachines()).to.throw(/malformed/);
});
});
});
Loading
Loading