Skip to content

Commit 41d1cb5

Browse files
VirtueMeclaude
andauthored
feat(stateMachines): add configureTaskTimeouts to mirror Lambda timeouts (#768)
Adds an opt-in `configureTaskTimeouts: true` flag on a state machine that auto-injects `TimeoutSeconds` into Task states invoking project Lambdas. Bridges the gap that Lambda timeouts don't surface as `States.Timeout`. - Pure planner returns Decision[] (inject | warn-overlong); thin applier mutates the definition and emits warnings via injected log callback - Resolves both legacy direct invoke (Resource: Fn::GetAtt) and service integration (arn:aws:states:::lambda:invoke[.sync|.waitForTaskToken]) - Recurses into Parallel branches and Map ItemProcessor/Iterator - Preserves user-set TimeoutSeconds/TimeoutSecondsPath; warns only when user TimeoutSeconds is strictly greater than the Lambda timeout - Fallback chain: function.timeout -> provider.timeout -> 6 (Serverless Framework default) - Integration fixture asserts CF template contains expected timeouts Closes #239 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 74d7d1e commit 41d1cb5

10 files changed

Lines changed: 795 additions & 2 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,23 @@ stepFunctions:
495495
...
496496
```
497497

498+
### Auto-configure Task timeouts
499+
500+
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`.
501+
502+
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.
503+
504+
```yaml
505+
stepFunctions:
506+
stateMachines:
507+
hellostepfunc1:
508+
configureTaskTimeouts: true
509+
definition:
510+
...
511+
```
512+
513+
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.
514+
498515
### Pre-deployment validation
499516

500517
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).
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
'use strict';
2+
3+
module.exports.fn = async () => ({ statusCode: 200 });
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
service: integration-configure-task-timeouts
2+
3+
provider: ${file(../base.yml):provider}
4+
plugins: ${file(../base.yml):plugins}
5+
package: ${file(../base.yml):package}
6+
custom: ${file(../base.yml):custom}
7+
8+
functions:
9+
short:
10+
handler: handler.fn
11+
timeout: 12
12+
long:
13+
handler: handler.fn
14+
timeout: 90
15+
defaulted:
16+
handler: handler.fn
17+
18+
stepFunctions:
19+
stateMachines:
20+
autoTimeoutsMachine:
21+
name: integration-configure-task-timeouts-${opt:stage, 'test'}
22+
configureTaskTimeouts: true
23+
definition:
24+
StartAt: DirectInvoke
25+
States:
26+
DirectInvoke:
27+
Type: Task
28+
Resource:
29+
Fn::GetAtt: [ShortLambdaFunction, Arn]
30+
Next: ServiceIntegration
31+
ServiceIntegration:
32+
Type: Task
33+
Resource: arn:aws:states:::lambda:invoke
34+
Parameters:
35+
FunctionName:
36+
Fn::GetAtt: [LongLambdaFunction, Arn]
37+
Payload.$: $
38+
Next: UserSetTimeout
39+
UserSetTimeout:
40+
Type: Task
41+
Resource:
42+
Fn::GetAtt: [ShortLambdaFunction, Arn]
43+
TimeoutSeconds: 5
44+
Next: ParallelStep
45+
ParallelStep:
46+
Type: Parallel
47+
End: true
48+
Branches:
49+
- StartAt: NestedTask
50+
States:
51+
NestedTask:
52+
Type: Task
53+
Resource:
54+
Fn::GetAtt: [DefaultedLambdaFunction, Arn]
55+
End: true
56+
backCompatMachine:
57+
name: integration-back-compat-task-timeouts-${opt:stage, 'test'}
58+
definition:
59+
StartAt: NoInjection
60+
States:
61+
NoInjection:
62+
Type: Task
63+
Resource:
64+
Fn::GetAtt: [ShortLambdaFunction, Arn]
65+
End: true
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
const expect = require('chai').expect;
6+
7+
const templatePath = path.join(__dirname, '.serverless', 'cloudformation-template-update-stack.json');
8+
9+
const findStateMachine = (resources, namePrefix) => {
10+
const entry = Object.values(resources).find(
11+
(r) => r.Type === 'AWS::StepFunctions::StateMachine'
12+
&& typeof r.Properties.StateMachineName === 'string'
13+
&& r.Properties.StateMachineName.startsWith(namePrefix),
14+
);
15+
return entry || null;
16+
};
17+
18+
const parseDefinition = (stateMachine) => {
19+
const ds = stateMachine.Properties.DefinitionString;
20+
if (typeof ds === 'string') return JSON.parse(ds);
21+
const sub = ds['Fn::Sub'];
22+
return JSON.parse(Array.isArray(sub) ? sub[0] : sub);
23+
};
24+
25+
describe('configure-task-timeouts fixture — CloudFormation template', () => {
26+
let resources;
27+
28+
before(() => {
29+
const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
30+
resources = template.Resources;
31+
});
32+
33+
describe('autoTimeoutsMachine (configureTaskTimeouts: true)', () => {
34+
let definition;
35+
36+
before(() => {
37+
const sm = findStateMachine(resources, 'integration-configure-task-timeouts-');
38+
expect(sm, 'auto-timeouts state machine should exist').to.not.equal(null);
39+
definition = parseDefinition(sm);
40+
});
41+
42+
it('injects TimeoutSeconds for legacy direct invoke (Resource: Fn::GetAtt) using function.timeout', () => {
43+
expect(definition.States.DirectInvoke.TimeoutSeconds).to.equal(12);
44+
});
45+
46+
it('injects TimeoutSeconds for service-integration lambda:invoke using function.timeout', () => {
47+
expect(definition.States.ServiceIntegration.TimeoutSeconds).to.equal(90);
48+
});
49+
50+
it('preserves a user-set TimeoutSeconds and does not overwrite it', () => {
51+
expect(definition.States.UserSetTimeout.TimeoutSeconds).to.equal(5);
52+
});
53+
54+
it('falls back to the Serverless Framework default (6s) when the function has no timeout configured', () => {
55+
const nested = definition.States.ParallelStep.Branches[0].States.NestedTask;
56+
expect(nested.TimeoutSeconds).to.equal(6);
57+
});
58+
});
59+
60+
describe('backCompatMachine (configureTaskTimeouts unset)', () => {
61+
it('does not inject TimeoutSeconds when the flag is off', () => {
62+
const sm = findStateMachine(resources, 'integration-back-compat-task-timeouts-');
63+
expect(sm, 'back-compat state machine should exist').to.not.equal(null);
64+
const definition = parseDefinition(sm);
65+
expect(definition.States.NoInjection.TimeoutSeconds).to.equal(undefined);
66+
});
67+
});
68+
});

fixtures/package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/deploy/stepFunctions/compileStateMachines.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const aslValidator = require('asl-validator');
55
const BbPromise = require('bluebird');
66
const crypto = require('node:crypto');
77
const schema = require('./compileStateMachines.schema');
8+
const configureTaskTimeouts = require('./configureTaskTimeouts');
89
const {
910
isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion, resolveLambdaFunctionName,
1011
} = require('../../utils/aws');
@@ -135,6 +136,15 @@ module.exports = {
135136
DefinitionString = JSON.stringify(stateMachineObj.definition)
136137
.replace(/\\n|\\r|\\n\\r/g, '');
137138
} else {
139+
if (stateMachineObj.configureTaskTimeouts === true) {
140+
configureTaskTimeouts({
141+
definition: stateMachineObj.definition,
142+
functions: this.serverless.service.functions,
143+
providerTimeout: this.serverless.service.provider.timeout,
144+
getLambdaLogicalId: (key) => this.provider.naming.getLambdaLogicalId(key),
145+
stateMachineName,
146+
});
147+
}
138148
const functionMappings = Array.from(getIntrinsicFunctions(stateMachineObj.definition));
139149
const { replaced, definition } = replacePseudoParameters(stateMachineObj.definition);
140150
const definitionString = JSON.stringify(definition, undefined, 2);

lib/deploy/stepFunctions/compileStateMachines.schema.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const events = Joi.array();
7272
const alarms = Joi.object();
7373
const notifications = Joi.object();
7474
const useExactVersion = Joi.boolean().default(false);
75+
const configureTaskTimeouts = Joi.boolean().default(false);
7576
const type = Joi.string().valid('STANDARD', 'EXPRESS').default('STANDARD');
7677
const retain = Joi.boolean().default(false);
7778

@@ -81,6 +82,7 @@ const schema = Joi.object().keys({
8182
name,
8283
role: arn,
8384
useExactVersion,
85+
configureTaskTimeouts,
8486
definition: definition.required(),
8587
dependsOn,
8688
tags,

lib/deploy/stepFunctions/compileStateMachines.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2239,4 +2239,58 @@ describe('#compileStateMachines', () => {
22392239
expect(result).to.have.property('then').that.is.a('function');
22402240
return result;
22412241
});
2242+
2243+
describe('configureTaskTimeouts', () => {
2244+
beforeEach(() => {
2245+
serverless.service.functions = {
2246+
hello: { handler: 'h.fn', timeout: 30 },
2247+
};
2248+
});
2249+
2250+
const buildSm = (extra) => ({
2251+
stateMachines: {
2252+
myStateMachine1: {
2253+
id: 'Test',
2254+
...extra,
2255+
definition: {
2256+
StartAt: 'Hello',
2257+
States: {
2258+
Hello: {
2259+
Type: 'Task',
2260+
Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] },
2261+
End: true,
2262+
},
2263+
},
2264+
},
2265+
},
2266+
},
2267+
});
2268+
2269+
const compileAndGetState = () => {
2270+
serverlessStepFunctions.compileStateMachines();
2271+
const stateMachine = serverlessStepFunctions.serverless.service
2272+
.provider.compiledCloudFormationTemplate.Resources.Test;
2273+
const subContent = stateMachine.Properties.DefinitionString['Fn::Sub'];
2274+
const definitionJson = Array.isArray(subContent) ? subContent[0] : subContent;
2275+
const parsed = JSON.parse(definitionJson);
2276+
return parsed.States.Hello;
2277+
};
2278+
2279+
it('does not inject TimeoutSeconds when configureTaskTimeouts is not set', () => {
2280+
serverless.service.stepFunctions = buildSm();
2281+
const hello = compileAndGetState();
2282+
expect(hello.TimeoutSeconds).to.equal(undefined);
2283+
});
2284+
2285+
it('injects TimeoutSeconds from the lambda config when configureTaskTimeouts is true', () => {
2286+
serverless.service.stepFunctions = buildSm({ configureTaskTimeouts: true });
2287+
const hello = compileAndGetState();
2288+
expect(hello.TimeoutSeconds).to.equal(30);
2289+
});
2290+
2291+
it('rejects unknown configureTaskTimeouts values via schema', () => {
2292+
serverless.service.stepFunctions = buildSm({ configureTaskTimeouts: 'yes' });
2293+
expect(() => serverlessStepFunctions.compileStateMachines()).to.throw(/malformed/);
2294+
});
2295+
});
22422296
});

0 commit comments

Comments
 (0)