Skip to content

Commit 77e9fc6

Browse files
authored
fix(stepfunctions): the catch field in CustomState is not rendered (#29654)
### Issue # (if applicable) N/A ### Reason for this change Customers that specify `Catch` fields in their CustomState's `stateJson` do not have Catchers defined in the rendered state definition. The reason for this is that the `Catch` fields from the `stateJson` is overridden by Catchers added through `addCatch()`. ### Description of changes This change updates the way the state's `Catch` field is rendered to merge Catchers provided inline with those provided through `addCatch()`. Catchers from `addCatch()` will be rendered first, followed by those provided inline. This is consistent with the merge behaviour for Retriers. ### Description of how you validated changes Unit test coverage for Catchers provided just inline, just through `addCatch()`, and a combination of both. ### Checklist - [X] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent b0e0353 commit 77e9fc6

File tree

4 files changed

+291
-12
lines changed

4 files changed

+291
-12
lines changed

packages/@aws-cdk-testing/framework-integ/test/aws-stepfunctions/test/integ.custom-state.js.snapshot/aws-stepfunctions-custom-state-integ.template.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"StateMachine2E01A3A5": {
2121
"Type": "AWS::StepFunctions::StateMachine",
2222
"Properties": {
23-
"DefinitionString": "{\"StartAt\":\"my custom task\",\"States\":{\"my custom task\":{\"Next\":\"my custom task with inline Retriers\",\"Type\":\"Task\",\"Resource\":\"arn:aws:states:::dynamodb:putItem\",\"Parameters\":{\"TableName\":\"my-cool-table\",\"Item\":{\"id\":{\"S\":\"my-entry\"}}},\"ResultPath\":null,\"Retry\":[{\"ErrorEquals\":[\"States.Timeout\"],\"IntervalSeconds\":10,\"MaxAttempts\":5},{\"ErrorEquals\":[\"States.Permissions\"],\"IntervalSeconds\":20,\"MaxAttempts\":2}],\"Catch\":[{\"ErrorEquals\":[\"States.ALL\"],\"Next\":\"failed\"}]},\"my custom task with inline Retriers\":{\"Next\":\"final step\",\"Type\":\"Task\",\"Resource\":\"arn:aws:states:::dynamodb:putItem\",\"Parameters\":{\"TableName\":\"my-cool-table\",\"Item\":{\"id\":{\"S\":\"my-entry\"}}},\"ResultPath\":null,\"Retry\":[{\"ErrorEquals\":[\"States.Permissions\"],\"IntervalSeconds\":20,\"MaxAttempts\":2}]},\"final step\":{\"Type\":\"Pass\",\"End\":true},\"failed\":{\"Type\":\"Fail\",\"Error\":\"DidNotWork\",\"Cause\":\"We got stuck\"}},\"TimeoutSeconds\":30}",
23+
"DefinitionString": "{\"StartAt\":\"my custom task\",\"States\":{\"my custom task\":{\"Next\":\"my custom task with inline Retriers\",\"Type\":\"Task\",\"Resource\":\"arn:aws:states:::dynamodb:putItem\",\"Parameters\":{\"TableName\":\"my-cool-table\",\"Item\":{\"id\":{\"S\":\"my-entry\"}}},\"ResultPath\":null,\"Retry\":[{\"ErrorEquals\":[\"States.Timeout\"],\"IntervalSeconds\":10,\"MaxAttempts\":5}],\"Catch\":[{\"ErrorEquals\":[\"States.ALL\"],\"Next\":\"failed\"}]},\"my custom task with inline Retriers\":{\"Next\":\"my custom task with inline Catchers\",\"Type\":\"Task\",\"Resource\":\"arn:aws:states:::dynamodb:putItem\",\"Parameters\":{\"TableName\":\"my-cool-table\",\"Item\":{\"id\":{\"S\":\"my-entry\"}}},\"ResultPath\":null,\"Retry\":[{\"ErrorEquals\":[\"States.Permissions\"],\"IntervalSeconds\":20,\"MaxAttempts\":2}]},\"my custom task with inline Catchers\":{\"Next\":\"final step\",\"Type\":\"Task\",\"Resource\":\"arn:aws:states:::dynamodb:putItem\",\"Parameters\":{\"TableName\":\"my-cool-table\",\"Item\":{\"id\":{\"S\":\"my-entry\"}}},\"ResultPath\":null,\"Catch\":[{\"ErrorEquals\":[\"States.Permissions\"],\"Next\":\"failed\"}]},\"final step\":{\"Type\":\"Pass\",\"End\":true},\"failed\":{\"Type\":\"Fail\",\"Error\":\"DidNotWork\",\"Cause\":\"We got stuck\"}},\"TimeoutSeconds\":30}",
2424
"RoleArn": {
2525
"Fn::GetAtt": [
2626
"StateMachineRoleB840431D",

packages/@aws-cdk-testing/framework-integ/test/aws-stepfunctions/test/integ.custom-state.ts

+22-10
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,6 @@ const stateJson = {
2323
},
2424
},
2525
ResultPath: null,
26-
Retry: [{
27-
ErrorEquals: [sfn.Errors.PERMISSIONS],
28-
IntervalSeconds: 20,
29-
MaxAttempts: 2,
30-
}],
3126
};
3227

3328
const failure = new sfn.Fail(stack, 'failed', {
@@ -39,18 +34,35 @@ const custom = new sfn.CustomState(stack, 'my custom task', {
3934
stateJson,
4035
});
4136

42-
const customWithInlineRetry = new sfn.CustomState(stack, 'my custom task with inline Retriers', {
43-
stateJson,
44-
});
45-
4637
custom.addCatch(failure);
4738
custom.addRetry({
4839
errors: [sfn.Errors.TIMEOUT],
4940
interval: cdk.Duration.seconds(10),
5041
maxAttempts: 5,
5142
});
5243

53-
const chain = sfn.Chain.start(custom).next(customWithInlineRetry).next(finalStatus);
44+
const customWithInlineRetry = new sfn.CustomState(stack, 'my custom task with inline Retriers', {
45+
stateJson: {
46+
...stateJson,
47+
Retry: [{
48+
ErrorEquals: [sfn.Errors.PERMISSIONS],
49+
IntervalSeconds: 20,
50+
MaxAttempts: 2,
51+
}],
52+
},
53+
});
54+
55+
const customWithInlineCatch = new sfn.CustomState(stack, 'my custom task with inline Catchers', {
56+
stateJson: {
57+
...stateJson,
58+
Catch: [{
59+
ErrorEquals: [sfn.Errors.PERMISSIONS],
60+
Next: 'failed',
61+
}],
62+
},
63+
});
64+
65+
const chain = sfn.Chain.start(custom).next(customWithInlineRetry).next(customWithInlineCatch).next(finalStatus);
5466

5567
const sm = new sfn.StateMachine(stack, 'StateMachine', {
5668
definition: chain,

packages/aws-cdk-lib/aws-stepfunctions/lib/states/custom-state.ts

+55-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Construct } from 'constructs';
22
import { State } from './state';
33
import { Chain } from '..';
4+
import { Annotations } from '../../../core/';
45
import { CatchProps, IChainable, INextable, RetryProps } from '../types';
56

67
/**
@@ -74,11 +75,64 @@ export class CustomState extends State implements IChainable, INextable {
7475
...this.renderRetryCatch(),
7576
};
7677

77-
// merge the Retry field defined in the stateJson into the state
78+
if (this.hasMultipleRetrySources(state)) {
79+
this.addMultipleRetrySourcesWarning();
80+
}
81+
82+
if (this.hasMultipleCatchSources(state)) {
83+
this.addMultipleCatchSourcesWarning();
84+
}
85+
86+
// Retriers and Catchers can be specified directly in the stateJson or indirectly to the construct with addRetry() and addCatch().
87+
// renderRetryCatch() only renders the indirectly supplied Retriers and Catchers, so we need to manually merge in those directly in the stateJson
7888
if (Array.isArray(this.stateJson.Retry)) {
7989
state.Retry = Array.isArray(state.Retry) ? [...state.Retry, ...this.stateJson.Retry] : [...this.stateJson.Retry];
8090
}
8191

92+
if (Array.isArray(this.stateJson.Catch)) {
93+
state.Catch = Array.isArray(state.Catch) ? [...state.Catch, ...this.stateJson.Catch] : [...this.stateJson.Catch];
94+
}
95+
8296
return state;
8397
}
98+
99+
private hasMultipleRetrySources(state: any): boolean {
100+
if (!Array.isArray(state.Retry)) {
101+
return false;
102+
}
103+
104+
if (!Array.isArray(this.stateJson.Retry)) {
105+
return false;
106+
}
107+
108+
return state.Retry.length > 0 && this.stateJson.Retry.length > 0;
109+
}
110+
111+
private hasMultipleCatchSources(state: any): boolean {
112+
if (!Array.isArray(state.Catch)) {
113+
return false;
114+
}
115+
116+
if (!Array.isArray(this.stateJson.Catch)) {
117+
return false;
118+
}
119+
120+
return state.Catch.length > 0 && this.stateJson.Catch.length > 0;
121+
}
122+
123+
private addMultipleRetrySourcesWarning(): void {
124+
Annotations.of(this).addWarningV2('@aws-cdk/aws-stepfunctions:multipleRetrySources', [
125+
'CustomState constructs can configure state retries using the stateJson property or by using the addRetry() function.',
126+
'When retries are configured using both of these, the state definition\'s Retry field is generated ',
127+
'by first rendering retries from addRetry(), then rendering retries from the stateJson.',
128+
].join('\n'));
129+
}
130+
131+
private addMultipleCatchSourcesWarning(): void {
132+
Annotations.of(this).addWarningV2('@aws-cdk/aws-stepfunctions:multipleCatchSources', [
133+
'CustomState constructs can configure state catchers using the stateJson property or by using the addCatch() function.',
134+
'When catchers are configured using both of these, the state definition\'s Catch field is generated ',
135+
'by first rendering catchers from addCatch(), then rendering catchers from the stateJson.',
136+
].join('\n'));
137+
}
84138
}

packages/aws-cdk-lib/aws-stepfunctions/test/custom-state.test.ts

+213
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { render } from './private/render-util';
2+
import { Annotations, Match } from '../../assertions';
23
import * as cdk from '../../core';
34
import * as sfn from '../lib';
5+
import { Errors } from '../lib/types';
46

57
describe('Custom State', () => {
68
let stack: cdk.Stack;
@@ -309,4 +311,215 @@ describe('Custom State', () => {
309311
},
310312
);
311313
});
314+
315+
test('expect retry to merge when specifying strategy inline and through construct', () => {
316+
// GIVEN
317+
const custom = new sfn.CustomState(stack, 'Custom', {
318+
stateJson: {
319+
...stateJson,
320+
Retry: [{
321+
ErrorEquals: ['States.TaskFailed'],
322+
}],
323+
},
324+
}).addRetry({ errors: [Errors.TIMEOUT] });
325+
const chain = sfn.Chain.start(custom);
326+
327+
// THEN
328+
expect(render(stack, chain)).toStrictEqual(
329+
{
330+
StartAt: 'Custom',
331+
States: {
332+
Custom: {
333+
Type: 'Task',
334+
Resource: 'arn:aws:states:::dynamodb:putItem',
335+
Parameters: {
336+
TableName: 'MyTable',
337+
Item: {
338+
id: {
339+
S: 'MyEntry',
340+
},
341+
},
342+
},
343+
ResultPath: null,
344+
Retry: [
345+
{
346+
ErrorEquals: ['States.Timeout'],
347+
},
348+
{
349+
ErrorEquals: ['States.TaskFailed'],
350+
},
351+
],
352+
End: true,
353+
},
354+
},
355+
},
356+
);
357+
});
358+
359+
test('expect catch to not fail when specifying strategy inline', () => {
360+
// GIVEN
361+
const custom = new sfn.CustomState(stack, 'Custom', {
362+
stateJson: {
363+
...stateJson,
364+
Catch: [{
365+
ErrorEquals: ['States.TaskFailed'],
366+
Next: 'Failed',
367+
}],
368+
},
369+
});
370+
const chain = sfn.Chain.start(custom);
371+
372+
// THEN
373+
expect(render(stack, chain)).toStrictEqual(
374+
{
375+
StartAt: 'Custom',
376+
States: {
377+
Custom: {
378+
Type: 'Task',
379+
Resource: 'arn:aws:states:::dynamodb:putItem',
380+
Parameters: {
381+
TableName: 'MyTable',
382+
Item: {
383+
id: {
384+
S: 'MyEntry',
385+
},
386+
},
387+
},
388+
ResultPath: null,
389+
Catch: [{
390+
ErrorEquals: ['States.TaskFailed'],
391+
Next: 'Failed',
392+
}],
393+
End: true,
394+
},
395+
},
396+
},
397+
);
398+
});
399+
400+
test('expect catch to merge when specifying strategy inline and through construct', () => {
401+
// GIVEN
402+
const failure = new sfn.Fail(stack, 'Failed', {
403+
error: 'DidNotWork',
404+
cause: 'We got stuck',
405+
});
406+
407+
const custom = new sfn.CustomState(stack, 'Custom', {
408+
stateJson: {
409+
...stateJson,
410+
Catch: [{
411+
ErrorEquals: ['States.TaskFailed'],
412+
Next: 'Failed',
413+
}],
414+
},
415+
}).addCatch(failure, { errors: [Errors.TIMEOUT] });
416+
const chain = sfn.Chain.start(custom);
417+
418+
// THEN
419+
expect(render(stack, chain)).toStrictEqual(
420+
{
421+
StartAt: 'Custom',
422+
States: {
423+
Custom: {
424+
Type: 'Task',
425+
Resource: 'arn:aws:states:::dynamodb:putItem',
426+
Parameters: {
427+
TableName: 'MyTable',
428+
Item: {
429+
id: {
430+
S: 'MyEntry',
431+
},
432+
},
433+
},
434+
ResultPath: null,
435+
Catch: [
436+
{
437+
ErrorEquals: ['States.Timeout'],
438+
Next: 'Failed',
439+
}, {
440+
ErrorEquals: ['States.TaskFailed'],
441+
Next: 'Failed',
442+
},
443+
],
444+
End: true,
445+
},
446+
Failed: {
447+
Type: 'Fail',
448+
Error: 'DidNotWork',
449+
Cause: 'We got stuck',
450+
},
451+
},
452+
},
453+
);
454+
});
455+
456+
test('expect warning message to be emitted when retries specified both in stateJson and through addRetry()', () => {
457+
const customState = new sfn.CustomState(stack, 'my custom task', {
458+
stateJson: {
459+
Type: 'Task',
460+
Resource: 'arn:aws:states:::dynamodb:putItem',
461+
Parameters: {
462+
TableName: 'my-cool-table',
463+
Item: {
464+
id: {
465+
S: 'my-entry',
466+
},
467+
},
468+
},
469+
Retry: [{
470+
ErrorEquals: ['States.TaskFailed'],
471+
}],
472+
},
473+
});
474+
475+
customState.addRetry({
476+
errors: [sfn.Errors.TIMEOUT],
477+
interval: cdk.Duration.seconds(10),
478+
maxAttempts: 5,
479+
});
480+
481+
new sfn.StateMachine(stack, 'StateMachine', {
482+
definition: sfn.Chain.start(customState),
483+
timeout: cdk.Duration.seconds(30),
484+
});
485+
486+
Annotations.fromStack(stack).hasWarning('/Default/my custom task', Match.stringLikeRegexp('CustomState constructs can configure state retries'));
487+
});
488+
489+
test('expect warning message to be emitted when catchers specified both in stateJson and through addCatch()', () => {
490+
const customState = new sfn.CustomState(stack, 'my custom task', {
491+
stateJson: {
492+
Type: 'Task',
493+
Resource: 'arn:aws:states:::dynamodb:putItem',
494+
Parameters: {
495+
TableName: 'my-cool-table',
496+
Item: {
497+
id: {
498+
S: 'my-entry',
499+
},
500+
},
501+
},
502+
Catch: [
503+
{
504+
ErrorEquals: ['States.Timeout'],
505+
Next: 'Failed',
506+
},
507+
],
508+
},
509+
});
510+
511+
const failure = new sfn.Fail(stack, 'Failed', {
512+
error: 'DidNotWork',
513+
cause: 'We got stuck',
514+
});
515+
516+
customState.addCatch(failure, { errors: [Errors.TIMEOUT] });
517+
518+
new sfn.StateMachine(stack, 'StateMachine', {
519+
definition: sfn.Chain.start(customState),
520+
timeout: cdk.Duration.seconds(30),
521+
});
522+
523+
Annotations.fromStack(stack).hasWarning('/Default/my custom task', Match.stringLikeRegexp('CustomState constructs can configure state catchers'));
524+
});
312525
});

0 commit comments

Comments
 (0)