Step Functions makes it easy to draw a workflow and hard to notice it has no plan for failure until production. This post is the set of patterns I put into every state machine.
Retry: be specific, back off, add jitter
"ChargeCard": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "charge-card", "Payload.$": "$" },
"Retry": [
{
"ErrorEquals": ["Lambda.TooManyRequestsException", "Lambda.ServiceException", "Lambda.SdkClientException"],
"IntervalSeconds": 2, "BackoffRate": 2.0, "MaxAttempts": 6,
"MaxDelaySeconds": 60, "JitterStrategy": "FULL"
},
{
"ErrorEquals": ["PaymentGatewayTimeout"],
"IntervalSeconds": 5, "BackoffRate": 2.0, "MaxAttempts": 3
}
],
"Catch": [
{ "ErrorEquals": ["CardDeclined"], "ResultPath": "$.error", "Next": "NotifyDeclined" },
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "CompensateOrder" }
],
"Next": "ShipOrder"
}
Rules:
- Retriers are evaluated in order; put specific errors first.
- Never retry business errors (
CardDeclined). Throw them as named errors from your code and catch them. States.ALLin a Retry retries everything, including bugs. Use it only in Catch.JitterStrategy: FULLstops many executions retrying in lockstep after a shared outage.
Tip
ResultPath: "$.error"keeps the original input and adds the error object. Without it, Catch replaces your state with the error and the next state loses the order data.
Timeouts and heartbeats
A task with no timeout can wait for a year (the Standard workflow maximum). Set both:
"TimeoutSeconds": 300,
"HeartbeatSeconds": 60
For callback tasks (.waitForTaskToken), the worker must call SendTaskHeartbeat; otherwise States.HeartbeatTimeout tells you it died.
Idempotency
Retries mean a task may run more than once. Make every side effect idempotent:
- Pass a deterministic key, for example
$$.Execution.Nameplus step name, to the downstream API as an idempotency key. - Use conditional writes in DynamoDB:
"RecordPayment": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Parameters": {
"TableName": "payments",
"Item": { "pk": { "S.$": "$.orderId" }, "status": { "S": "CHARGED" } },
"ConditionExpression": "attribute_not_exists(pk)"
},
"Catch": [{ "ErrorEquals": ["DynamoDB.ConditionalCheckFailedException"], "Next": "AlreadyRecorded" }],
"Next": "ShipOrder"
}
- Start executions with a deterministic
--nameso a duplicate start fails instead of running twice (Standard workflows reject a reused name for 90 days).
aws stepfunctions start-execution --state-machine-arn $SM --name order-$ORDER_ID --input file://order.json
Compensation (saga)
When step 3 fails after steps 1 and 2 succeeded, Catch to a compensation branch that undoes them in reverse: refund the charge, release the stock. Each compensation step needs its own Retry; a failed refund is worse than a failed charge.
Distributed Map for large fan-out
Distributed Map runs child executions over items in S3 (CSV, JSON, object listing) with high concurrency.
"ProcessFiles": {
"Type": "Map",
"ItemReader": {
"Resource": "arn:aws:states:::s3:listObjectsV2",
"Parameters": { "Bucket": "acme-inbox", "Prefix": "2026/10/" }
},
"ItemProcessor": {
"ProcessorConfig": { "Mode": "DISTRIBUTED", "ExecutionType": "EXPRESS" },
"StartAt": "Transform",
"States": { "Transform": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "transform", "Payload.$": "$" }, "End": true } }
},
"MaxConcurrency": 200,
"ToleratedFailurePercentage": 2,
"ItemBatcher": { "MaxItemsPerBatch": 50 },
"ResultWriter": { "Resource": "arn:aws:states:::s3:putObject", "Parameters": { "Bucket": "acme-results", "Prefix": "runs/" } },
"End": true
}
- MaxConcurrency protects downstream services and Lambda concurrency. Size it from what the slowest dependency can handle.
- ToleratedFailurePercentage / Count lets the run finish when a few items are bad; failed items are listed in the ResultWriter output.
- ItemBatcher cuts per-item overhead and cost.
Redrive instead of restart
Standard workflows can be redriven from the failed state, keeping completed work:
aws stepfunctions redrive-execution --execution-arn $EXEC_ARN
Distributed Map runs can redrive only the failed child executions. Fix the bug or the dependency first; redrive does not change the definition.
Observe
aws stepfunctions list-executions --state-machine-arn $SM --status-filter FAILED --max-results 20
aws stepfunctions get-execution-history --execution-arn $EXEC_ARN --reverse-order --max-results 5
CloudWatch metrics ExecutionsFailed, ExecutionsTimedOut and ExecutionThrottled deserve alarms. Express workflows need CloudWatch Logs enabled to see anything about failures.
Prevent it next time
- Lint definitions in CI (
aws stepfunctions validate-state-machine-definition). - Every Task has a timeout, a specific Retry and a Catch.
- Chaos test: make a dependency return throttling errors in staging and watch the workflow recover.