tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.List app directory contents
ls /app && pwd
/app [stdout] /app
Check LocalStack health
curl -s http://localstack:4566/_localstack/health | head -50
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "disabled", "ses": "disabled", "sns": "disabled", "sqs": "available", "ssm": "disabled", "stepfunctions": "disabled", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}
[stdout]
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "disabled", "ses": "disabled", "sns": "disabled", "sqs": "available", "ssm": "disabled", "stepfunctions": "disabled", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
Verify tools and environment variables
which aws python3 jq zip && python3 --version && aws --version && echo "---env---" && env | grep -E "AWS_|LOCAL" | sort
/usr/local/bin/aws /opt/venv/bin/python3 /usr/bin/jq /usr/bin/zip Python 3.12.3 aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 ---env--- AWS_ACCESS_KEY_ID=test AWS_DEFAULT_REGION=us-east-1 AWS_ENDPOINT_URL=http://localstack:4566 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test [stdout] /usr/local/bin/aws /opt/venv/bin/python3 /usr/bin/jq /usr/bin/zip Python 3.12.3 aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 ---env--- AWS_ACCESS_KEY_ID=test AWS_DEFAULT_REGION=us-east-1 AWS_ENDPOINT_URL=http://localstack:4566 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test
Create build directory structure
mkdir -p /app/build /app/policies /app/lambdas/order-api /app/lambdas/outbox-relay && ls /app
build lambdas policies [stdout] build lambdas policies
Get account ID
aws sts get-caller-identity
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}Create KMS CMK and alias
KEY_ID=$(aws kms create-key --description "outbox CMK" --key-usage ENCRYPT_DECRYPT --query 'KeyMetadata.KeyId' --output text) echo "KEY_ID=$KEY_ID" aws kms create-alias --alias-name alias/outbox-cmk --target-key-id "$KEY_ID" KEY_ARN=$(aws kms describe-key --key-id alias/outbox-cmk --query 'KeyMetadata.Arn' --output text) echo "KEY_ARN=$KEY_ARN" echo "$KEY_ARN" > /app/build/kms_key_arn.txt echo "$KEY_ID" > /app/build/kms_key_id.txt
KEY_ID=a378d809-0047-4032-a731-a97d25d2ceb5 KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5 [stdout] KEY_ID=a378d809-0047-4032-a731-a97d25d2ceb5 KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5
Create three DynamoDB tables with CMK encryption
KEY_ARN=$(cat /app/build/kms_key_arn.txt) aws dynamodb create-table \ --table-name orders \ --attribute-definitions AttributeName=order_id,AttributeType=S \ --key-schema AttributeName=order_id,KeyType=HASH \ --billing-mode PAY_PER_REQUEST \ --sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=$KEY_ARN" \ --query 'TableDescription.TableArn' --output text aws dynamodb create-table \ --table-name outbox \ --attribute-definitions AttributeName=event_id,AttributeType=S \ --key-schema AttributeName=event_id,KeyType=HASH \ --billing-mode PAY_PER_REQUEST \ --stream-specification "StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES" \ --sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=$KEY_ARN" \ --query 'TableDescription.TableArn' --output text aws dynamodb create-table \ --table-name processed_events \ --attribute-definitions AttributeName=event_id,AttributeType=S \ --key-schema AttributeName=event_id,KeyType=HASH \ --billing-mode PAY_PER_REQUEST \ --sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=$KEY_ARN" \ --query 'TableDescription.TableArn' --output text aws dynamodb update-time-to-live \ --table-name processed_events \ --time-to-live-specification "Enabled=true,AttributeName=ttl" \ --query 'TimeToLiveSpecification' --output json
arn:aws:dynamodb:us-east-1:000000000000:table/orders
arn:aws:dynamodb:us-east-1:000000000000:table/outbox
arn:aws:dynamodb:us-east-1:000000000000:table/processed_events
{
"Enabled": true,
"AttributeName": "ttl"
}
[stdout]
arn:aws:dynamodb:us-east-1:000000000000:table/orders
arn:aws:dynamodb:us-east-1:000000000000:table/outbox
arn:aws:dynamodb:us-east-1:000000000000:table/processed_events
{
"Enabled": true,
"AttributeName": "ttl"
}Get outbox stream ARN and verify SSE
STREAM_ARN=$(aws dynamodb describe-table --table-name outbox --query 'Table.LatestStreamArn' --output text) echo "$STREAM_ARN" > /app/build/outbox_stream_arn.txt echo "STREAM_ARN=$STREAM_ARN" # verify SSE settings aws dynamodb describe-table --table-name orders --query 'Table.SSEDescription' aws dynamodb describe-table --table-name outbox --query 'Table.SSEDescription' aws dynamodb describe-table --table-name outbox --query 'Table.StreamSpecification'
STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
"StreamEnabled": true,
"StreamViewType": "NEW_AND_OLD_IMAGES"
}
[stdout]
STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5"
}
{
"StreamEnabled": true,
"StreamViewType": "NEW_AND_OLD_IMAGES"
}Create DLQs and main SQS queues with KMS and redrive
KEY_ID=$(cat /app/build/kms_key_id.txt)
# DLQs
DLQ_CREATED_URL=$(aws sqs create-queue \
--queue-name orders-created-dlq \
--attributes "{\"KmsMasterKeyId\":\"$KEY_ID\"}" \
--query 'QueueUrl' --output text)
DLQ_CANCELLED_URL=$(aws sqs create-queue \
--queue-name orders-cancelled-dlq \
--attributes "{\"KmsMasterKeyId\":\"$KEY_ID\"}" \
--query 'QueueUrl' --output text)
DLQ_CREATED_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_CREATED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
DLQ_CANCELLED_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_CANCELLED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
echo "DLQ_CREATED_ARN=$DLQ_CREATED_ARN"
echo "DLQ_CANCELLED_ARN=$DLQ_CANCELLED_ARN"
# main queues with redrive
RP_CREATED=$(jq -n --arg arn "$DLQ_CREATED_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring')
RP_CANCELLED=$(jq -n --arg arn "$DLQ_CANCELLED_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring')
CREATED_ATTRS=$(jq -n --arg key "$KEY_ID" --arg rp "$RP_CREATED" '{KmsMasterKeyId:$key, RedrivePolicy:$rp}')
CANCELLED_ATTRS=$(jq -n --arg key "$KEY_ID" --arg rp "$RP_CANCELLED" '{KmsMasterKeyId:$key, RedrivePolicy:$rp}')
CREATED_URL=$(aws sqs create-queue --queue-name orders-created --attributes "$CREATED_ATTRS" --query 'QueueUrl' --output text)
CANCELLED_URL=$(aws sqs create-queue --queue-name orders-cancelled --attributes "$CANCELLED_ATTRS" --query 'QueueUrl' --output text)
CREATED_ARN=$(aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
CANCELLED_ARN=$(aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
echo "CREATED_URL=$CREATED_URL"
echo "CANCELLED_URL=$CANCELLED_URL"
echo "CREATED_ARN=$CREATED_ARN"
echo "CANCELLED_ARN=$CANCELLED_ARN"
# persist
{
echo "DLQ_CREATED_URL=$DLQ_CREATED_URL"
echo "DLQ_CANCELLED_URL=$DLQ_CANCELLED_URL"
echo "DLQ_CREATED_ARN=$DLQ_CREATED_ARN"
echo "DLQ_CANCELLED_ARN=$DLQ_CANCELLED_ARN"
echo "CREATED_URL=$CREATED_URL"
echo "CANCELLED_URL=$CANCELLED_URL"
echo "CREATED_ARN=$CREATED_ARN"
echo "CANCELLED_ARN=$CANCELLED_ARN"
} > /app/build/queues.envDLQ_CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created-dlq DLQ_CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq CREATED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created CANCELLED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled [stdout] DLQ_CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created-dlq DLQ_CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq CREATED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-created CANCELLED_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/orders-cancelled CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled
Create event bus and two rules
source /app/build/queues.env
KEY_ARN=$(cat /app/build/kms_key_arn.txt)
# create bus with CMK
BUS_ARN=$(aws events create-event-bus \
--name order-events \
--kms-key-identifier "$KEY_ARN" \
--query 'EventBusArn' --output text)
echo "BUS_ARN=$BUS_ARN"
# Created rule
aws events put-rule \
--name on-order-created \
--event-bus-name order-events \
--event-pattern '{"source":["outbox.relay"],"detail-type":["OrderCreated"]}' \
--state ENABLED \
--query 'RuleArn' --output text
# Cancelled rule
aws events put-rule \
--name on-order-cancelled \
--event-bus-name order-events \
--event-pattern '{"source":["outbox.relay"],"detail-type":["OrderCancelled"]}' \
--state ENABLED \
--query 'RuleArn' --output text
echo "$BUS_ARN" > /app/build/bus_arn.txtBUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/order-events arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled [stdout] BUS_ARN=arn:aws:events:us-east-1:000000000000:event-bus/order-events arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled
Attach EventBridge targets and set queue policies
source /app/build/queues.env
# attach SQS targets to rules
aws events put-targets \
--event-bus-name order-events \
--rule on-order-created \
--targets "Id=created-q,Arn=$CREATED_ARN"
aws events put-targets \
--event-bus-name order-events \
--rule on-order-cancelled \
--targets "Id=cancelled-q,Arn=$CANCELLED_ARN"
# allow events.amazonaws.com to deliver to each queue (limited to specific rule)
RULE_CREATED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
RULE_CANCELLED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"
CREATED_POLICY=$(jq -n --arg q "$CREATED_ARN" --arg rule "$RULE_CREATED_ARN" '{
Version:"2012-10-17",
Statement:[{
Sid:"AllowEventBridgeRule",
Effect:"Allow",
Principal:{Service:"events.amazonaws.com"},
Action:"sqs:SendMessage",
Resource:$q,
Condition:{ArnEquals:{"aws:SourceArn":$rule}}
}]
}')
CANCELLED_POLICY=$(jq -n --arg q "$CANCELLED_ARN" --arg rule "$RULE_CANCELLED_ARN" '{
Version:"2012-10-17",
Statement:[{
Sid:"AllowEventBridgeRule",
Effect:"Allow",
Principal:{Service:"events.amazonaws.com"},
Action:"sqs:SendMessage",
Resource:$q,
Condition:{ArnEquals:{"aws:SourceArn":$rule}}
}]
}')
aws sqs set-queue-attributes --queue-url "$CREATED_URL" \
--attributes "$(jq -n --arg p "$CREATED_POLICY" '{Policy:$p}')"
aws sqs set-queue-attributes --queue-url "$CANCELLED_URL" \
--attributes "$(jq -n --arg p "$CANCELLED_POLICY" '{Policy:$p}')"
echo "targets attached, queue policies set"
aws events list-targets-by-rule --event-bus-name order-events --rule on-order-created --query 'Targets[].Arn' --output text
aws events list-targets-by-rule --event-bus-name order-events --rule on-order-cancelled --query 'Targets[].Arn' --output text{
"FailedEntryCount": 0,
"FailedEntries": []
}
{
"FailedEntryCount": 0,
"FailedEntries": []
}
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
targets attached, queue policies set
arn:aws:sqs:us-east-1:000000000000:orders-created
arn:aws:sqs:us-east-1:000000000000:orders-cancelled
[stdout]
{
"FailedEntryCount": 0,
"FailedEntries": []
}
{
"FailedEntryCount": 0,
"FailedEntries": []
}
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
targets attached, queue policies set
arn:aws:sqs:us-east-1:000000000000:orders-created
arn:aws:sqs:us-east-1:000000000000:orders-cancelledSet SQS queue policies via file
source /app/build/queues.env
RULE_CREATED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
RULE_CANCELLED_ARN="arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"
# build the inner policy doc
jq -n --arg q "$CREATED_ARN" --arg rule "$RULE_CREATED_ARN" '{
Version:"2012-10-17",
Statement:[{
Sid:"AllowEventBridgeRule",
Effect:"Allow",
Principal:{Service:"events.amazonaws.com"},
Action:"sqs:SendMessage",
Resource:$q,
Condition:{ArnEquals:{"aws:SourceArn":$rule}}
}]
}' > /tmp/created_policy.json
jq -n --arg q "$CANCELLED_ARN" --arg rule "$RULE_CANCELLED_ARN" '{
Version:"2012-10-17",
Statement:[{
Sid:"AllowEventBridgeRule",
Effect:"Allow",
Principal:{Service:"events.amazonaws.com"},
Action:"sqs:SendMessage",
Resource:$q,
Condition:{ArnEquals:{"aws:SourceArn":$rule}}
}]
}' > /tmp/cancelled_policy.json
# build the attributes wrapper using --rawfile to embed the doc as string
jq -n --rawfile p /tmp/created_policy.json '{Policy:$p}' > /tmp/created_attrs.json
jq -n --rawfile p /tmp/cancelled_policy.json '{Policy:$p}' > /tmp/cancelled_attrs.json
aws sqs set-queue-attributes --queue-url "$CREATED_URL" --attributes file:///tmp/created_attrs.json
aws sqs set-queue-attributes --queue-url "$CANCELLED_URL" --attributes file:///tmp/cancelled_attrs.json
aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names Policy --query 'Attributes.Policy' --output text | jq .
aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" --attribute-names Policy --query 'Attributes.Policy' --output text | jq .aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEventBridgeRule",
"Effect": "Allow",
"Principal": {
"Service": "events.amazonaws.com"
},
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:000000000000:orders-created",
"Condition": {
"ArnEquals": {
"aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
}
}
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEventBridgeRule",
"Effect": "Allow",
"Principal": {
"Service": "events.amazonaws.com"
},
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled",
"Condition": {
"ArnEquals": {
"aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"
}
}
}
]
}
[stdout]
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
aws: [ERROR]: An error occurred (InternalError) when calling the SetQueueAttributes operation (reached max retries: 2): exception while calling sqs.SetQueueAttributes: 'str' object has no attribute 'get'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEventBridgeRule",
"Effect": "Allow",
"Principal": {
"Service": "events.amazonaws.com"
},
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:000000000000:orders-created",
"Condition": {
"ArnEquals": {
"aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created"
}
}
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEventBridgeRule",
"Effect": "Allow",
"Principal": {
"Service": "events.amazonaws.com"
},
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled",
"Condition": {
"ArnEquals": {
"aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-cancelled"
}
}
}
]
}{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api",
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api:*"
]
},
{
"Sid": "OrdersOutboxTransact",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:TransactWriteItems"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "CmkForDdbSse",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "__KEY_ARN__",
"Condition": {
"StringEquals": {
"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
}
}
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay",
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay:*"
]
},
{
"Sid": "OutboxStreamRead",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator",
"dynamodb:ListStreams"
],
"Resource": "__OUTBOX_STREAM_ARN__"
},
{
"Sid": "DedupTable",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PublishToBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
},
{
"Sid": "CmkForDdbAndEventsSse",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "__KEY_ARN__",
"Condition": {
"StringEquals": {
"kms:ViaService": [
"dynamodb.us-east-1.amazonaws.com",
"events.us-east-1.amazonaws.com"
]
}
}
}
]
}
Create IAM roles and inline policies
KEY_ARN=$(cat /app/build/kms_key_arn.txt) STREAM_ARN=$(cat /app/build/outbox_stream_arn.txt) # substitute placeholders sed "s|__KEY_ARN__|$KEY_ARN|g" /app/policies/order-api-policy.json > /app/build/order-api-policy.json sed -e "s|__KEY_ARN__|$KEY_ARN|g" -e "s|__OUTBOX_STREAM_ARN__|$STREAM_ARN|g" /app/policies/outbox-relay-policy.json > /app/build/outbox-relay-policy.json # create roles aws iam create-role --role-name order-api-role \ --assume-role-policy-document file:///app/policies/lambda-trust.json \ --query 'Role.Arn' --output text > /app/build/order-api-role.arn aws iam create-role --role-name outbox-relay-role \ --assume-role-policy-document file:///app/policies/lambda-trust.json \ --query 'Role.Arn' --output text > /app/build/outbox-relay-role.arn cat /app/build/order-api-role.arn /app/build/outbox-relay-role.arn # attach inline policies aws iam put-role-policy --role-name order-api-role \ --policy-name order-api-inline \ --policy-document file:///app/build/order-api-policy.json aws iam put-role-policy --role-name outbox-relay-role \ --policy-name outbox-relay-inline \ --policy-document file:///app/build/outbox-relay-policy.json echo "--- order-api inline policy ---" aws iam get-role-policy --role-name order-api-role --policy-name order-api-inline --query 'PolicyDocument' --output json echo "--- outbox-relay inline policy ---" aws iam get-role-policy --role-name outbox-relay-role --policy-name outbox-relay-inline --query 'PolicyDocument' --output json
arn:aws:iam::000000000000:role/order-api-role
arn:aws:iam::000000000000:role/outbox-relay-role
--- order-api inline policy ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api",
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api:*"
]
},
{
"Sid": "OrdersOutboxTransact",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:TransactWriteItems"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "CmkForDdbSse",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
"Condition": {
"StringEquals": {
"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
}
}
}
]
}
--- outbox-relay inline policy ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay",
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay:*"
]
},
{
"Sid": "OutboxStreamRead",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator",
"dynamodb:ListStreams"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
},
{
"Sid": "DedupTable",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PublishToBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
},
{
"Sid": "CmkForDdbAndEventsSse",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
"Condition": {
"StringEquals": {
"kms:ViaService": [
"dynamodb.us-east-1.amazonaws.com",
"events.us-east-1.amazonaws.com"
]
}
}
}
]
}
[stdout]
arn:aws:iam::000000000000:role/order-api-role
arn:aws:iam::000000000000:role/outbox-relay-role
--- order-api inline policy ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api",
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api:*"
]
},
{
"Sid": "OrdersOutboxTransact",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:TransactWriteItems"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "CmkForDdbSse",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
"Condition": {
"StringEquals": {
"kms:ViaService": "dynamodb.us-east-1.amazonaws.com"
}
}
}
]
}
--- outbox-relay inline policy ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay",
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay:*"
]
},
{
"Sid": "OutboxStreamRead",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator",
"dynamodb:ListStreams"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
},
{
"Sid": "DedupTable",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PublishToBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
},
{
"Sid": "CmkForDdbAndEventsSse",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/a378d809-0047-4032-a731-a97d25d2ceb5",
"Condition": {
"StringEquals": {
"kms:ViaService": [
"dynamodb.us-east-1.amazonaws.com",
"events.us-east-1.amazonaws.com"
]
}
}
}
]
}import json
import os
import uuid
from datetime import datetime, timezone
import boto3
from botocore.exceptions import ClientError
ORDERS_TABLE = os.environ["ORDERS_TABLE"]
OUTBOX_TABLE = os.environ["OUTBOX_TABLE"]
VALID_KINDS = {"OrderCreated", "OrderCancelled"}
_ddb = boto3.client("dynamodb")
def _to_attr(value):
if value is None:
return {"NULL": True}
if isinstance(value, bool):
return {"BOOL": value}
if isinstance(value, (int, float)):
return {"N": str(value)}
if isinstance(value, str):
return {"S": value}
if isinstance(value, list):
return {"L": [_to_attr(v) for v in value]}
if isinstance(value, dict):
return {"M": {k: _to_attr(v) for k, v in value.items()}}
return {"S": str(value)}
def handler(event, context):
if not isinstance(event, dict):
return {"statusCode": 400, "body": "event must be an object"}
order_id = event.get("order_id")
kind = event.get("kind")
if not order_id or not isinstance(order_id, str):
return {"statusCode": 400, "body": "order_id required"}
if kind not in VALID_KINDS:
return {"statusCode": 400, "body": f"kind must be one of {sorted(VALID_KINDS)}"}
event_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
payload = {k: v for k, v in event.items() if k not in ("order_id", "kind")}
order_item = {
"order_id": {"S": order_id},
"kind": {"S": kind},
"updated_at": {"S": now},
}
for k, v in payload.items():
order_item[k] = _to_attr(v)
outbox_item = {
"event_id": {"S": event_id},
"order_id": {"S": order_id},
"kind": {"S": kind},
"created_at": {"S": now},
"payload": _to_attr({"order_id": order_id, "kind": kind, **payload}),
}
try:
_ddb.transact_write_items(
TransactItems=[
{"Put": {"TableName": ORDERS_TABLE, "Item": order_item}},
{"Put": {"TableName": OUTBOX_TABLE, "Item": outbox_item}},
]
)
except ClientError as e:
return {
"statusCode": 500,
"body": json.dumps({"error": e.response["Error"]["Code"], "message": str(e)}),
}
return {
"statusCode": 200,
"body": json.dumps({"order_id": order_id, "event_id": event_id, "kind": kind}),
}
import json
import logging
import os
import time
import boto3
from botocore.exceptions import ClientError
logger = logging.getLogger()
logger.setLevel(logging.INFO)
DEDUP_TABLE = os.environ["DEDUP_TABLE"]
EVENT_BUS = os.environ["EVENT_BUS"]
DEDUP_TTL_SECONDS = int(os.environ.get("DEDUP_TTL_SECONDS", "86400"))
EVENT_SOURCE = "outbox.relay"
_ddb = boto3.client("dynamodb")
_events = boto3.client("events")
def _from_attr(attr):
if "S" in attr:
return attr["S"]
if "N" in attr:
n = attr["N"]
return int(n) if "." not in n else float(n)
if "BOOL" in attr:
return attr["BOOL"]
if "NULL" in attr:
return None
if "L" in attr:
return [_from_attr(x) for x in attr["L"]]
if "M" in attr:
return {k: _from_attr(v) for k, v in attr["M"].items()}
if "SS" in attr:
return list(attr["SS"])
if "NS" in attr:
return [int(x) if "." not in x else float(x) for x in attr["NS"]]
return None
def _image_to_dict(image):
return {k: _from_attr(v) for k, v in image.items()}
def _already_processed(event_id):
resp = _ddb.get_item(
TableName=DEDUP_TABLE,
Key={"event_id": {"S": event_id}},
ConsistentRead=True,
)
return "Item" in resp
def _mark_processed(event_id):
ttl = int(time.time()) + DEDUP_TTL_SECONDS
_ddb.put_item(
TableName=DEDUP_TABLE,
Item={
"event_id": {"S": event_id},
"ttl": {"N": str(ttl)},
"processed_at": {"N": str(int(time.time()))},
},
)
def handler(event, context):
failures = []
records = event.get("Records", [])
for record in records:
seq = record.get("dynamodb", {}).get("SequenceNumber") or record.get("eventID")
try:
ddb = record.get("dynamodb", {})
new_image = ddb.get("NewImage")
if not new_image:
logger.info("skipping record with no NewImage (event=%s)", record.get("eventName"))
continue
row = _image_to_dict(new_image)
event_id = row.get("event_id")
kind = row.get("kind")
if not event_id or not kind:
logger.warning("record missing event_id/kind: %s", row)
continue
if _already_processed(event_id):
logger.info("dedup hit for event_id=%s; skipping", event_id)
continue
detail = {
"event_id": event_id,
"order_id": row.get("order_id"),
"kind": kind,
"created_at": row.get("created_at"),
"payload": row.get("payload"),
}
resp = _events.put_events(
Entries=[
{
"EventBusName": EVENT_BUS,
"Source": EVENT_SOURCE,
"DetailType": kind,
"Detail": json.dumps(detail, default=str),
}
]
)
failed = resp.get("FailedEntryCount", 0)
if failed:
logger.error("PutEvents reported failures: %s", resp.get("Entries"))
failures.append({"itemIdentifier": seq})
continue
_mark_processed(event_id)
logger.info("relayed event_id=%s kind=%s", event_id, kind)
except ClientError as e:
logger.exception("ClientError handling record seq=%s: %s", seq, e)
failures.append({"itemIdentifier": seq})
except Exception as e:
logger.exception("Unhandled error for record seq=%s: %s", seq, e)
failures.append({"itemIdentifier": seq})
return {"batchItemFailures": failures}
Package lambdas as zip files
cd /app/lambdas/order-api && zip -q -r /app/build/order-api.zip handler.py cd /app/lambdas/outbox-relay && zip -q -r /app/build/outbox-relay.zip handler.py ls -la /app/build/*.zip
-rw-r--r-- 1 root root 1000 Apr 28 15:13 /app/build/order-api.zip -rw-r--r-- 1 root root 1385 Apr 28 15:13 /app/build/outbox-relay.zip [stdout] -rw-r--r-- 1 root root 1000 Apr 28 15:13 /app/build/order-api.zip -rw-r--r-- 1 root root 1385 Apr 28 15:13 /app/build/outbox-relay.zip
Deploy both lambdas and wait for Active
ORDER_API_ROLE=$(cat /app/build/order-api-role.arn)
RELAY_ROLE=$(cat /app/build/outbox-relay-role.arn)
# create order-api lambda
aws lambda create-function \
--function-name order-api \
--runtime python3.11 \
--handler handler.handler \
--role "$ORDER_API_ROLE" \
--zip-file fileb:///app/build/order-api.zip \
--timeout 15 \
--environment "Variables={ORDERS_TABLE=orders,OUTBOX_TABLE=outbox,AWS_ENDPOINT_URL=http://localstack:4566}" \
--query 'FunctionArn' --output text
# create outbox-relay lambda
aws lambda create-function \
--function-name outbox-relay \
--runtime python3.11 \
--handler handler.handler \
--role "$RELAY_ROLE" \
--zip-file fileb:///app/build/outbox-relay.zip \
--timeout 30 \
--environment "Variables={DEDUP_TABLE=processed_events,EVENT_BUS=order-events,AWS_ENDPOINT_URL=http://localstack:4566}" \
--query 'FunctionArn' --output text
echo "--- waiting for both to be Active ---"
aws lambda wait function-active --function-name order-api
aws lambda wait function-active --function-name outbox-relay
aws lambda get-function --function-name order-api --query 'Configuration.[State,LastUpdateStatus]' --output text
aws lambda get-function --function-name outbox-relay --query 'Configuration.[State,LastUpdateStatus]' --output textarn:aws:lambda:us-east-1:000000000000:function:order-api arn:aws:lambda:us-east-1:000000000000:function:outbox-relay --- waiting for both to be Active --- Active Successful Active Successful [stdout] arn:aws:lambda:us-east-1:000000000000:function:order-api arn:aws:lambda:us-east-1:000000000000:function:outbox-relay --- waiting for both to be Active --- Active Successful Active Successful
Wire stream to relay with ReportBatchItemFailures
STREAM_ARN=$(cat /app/build/outbox_stream_arn.txt)
aws lambda create-event-source-mapping \
--function-name outbox-relay \
--event-source-arn "$STREAM_ARN" \
--starting-position TRIM_HORIZON \
--batch-size 10 \
--maximum-batching-window-in-seconds 1 \
--function-response-types ReportBatchItemFailures \
--query '{UUID:UUID,State:State,FunctionResponseTypes:FunctionResponseTypes}' --output json
aws lambda list-event-source-mappings --function-name outbox-relay --query 'EventSourceMappings[].{State:State,FunctionResponseTypes:FunctionResponseTypes,EventSourceArn:EventSourceArn}' --output json{
"UUID": "d49652bd-49cd-4da3-8d1e-85227868cdb3",
"State": "Creating",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
]
}
[
{
"State": "Enabled",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"EventSourceArn": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
}
]
[stdout]
{
"UUID": "d49652bd-49cd-4da3-8d1e-85227868cdb3",
"State": "Creating",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
]
}
[
{
"State": "Enabled",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"EventSourceArn": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T15:10:08.104"
}
]Invoke order-api with OrderCreated
source /app/build/queues.env
# invoke OrderCreated
PAYLOAD1=$(jq -c -n '{order_id:"o-1", kind:"OrderCreated", customer_id:"c-42", total_cents:1999, currency:"USD"}')
aws lambda invoke \
--function-name order-api \
--cli-binary-format raw-in-base64-out \
--payload "$PAYLOAD1" \
/tmp/resp1.json --query 'StatusCode' --output text
cat /tmp/resp1.json
echo200
{"statusCode": 200, "body": "{\"order_id\": \"o-1\", \"event_id\": \"4f74c998-6158-4393-a5b1-05664d6ad1d8\", \"kind\": \"OrderCreated\"}"}
[stdout]
200
{"statusCode": 200, "body": "{\"order_id\": \"o-1\", \"event_id\": \"4f74c998-6158-4393-a5b1-05664d6ad1d8\", \"kind\": \"OrderCreated\"}"}Verify OrderCreated flow
source /app/build/queues.env # wait for stream → relay → eventbridge → sqs propagation sleep 8 echo "--- orders-created ApproxNumberOfMessages ---" aws sqs get-queue-attributes --queue-url "$CREATED_URL" \ --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \ --query 'Attributes' --output json echo "--- orders-cancelled ApproxNumberOfMessages ---" aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" \ --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \ --query 'Attributes' --output json echo "--- receive from orders-created ---" aws sqs receive-message --queue-url "$CREATED_URL" --max-number-of-messages 10 --wait-time-seconds 2 --query 'Messages[].Body' --output json | jq '.[] | fromjson' echo "--- processed_events scan ---" aws dynamodb scan --table-name processed_events --query 'Items' --output json
--- orders-created ApproxNumberOfMessages ---
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled ApproxNumberOfMessages ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- receive from orders-created ---
aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'
--- processed_events scan ---
[
{
"ttl": {
"N": "1777475668"
},
"processed_at": {
"N": "1777389268"
},
"event_id": {
"S": "4f74c998-6158-4393-a5b1-05664d6ad1d8"
}
}
]
[stdout]
--- orders-created ApproxNumberOfMessages ---
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled ApproxNumberOfMessages ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- receive from orders-created ---
aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'
--- processed_events scan ---
[
{
"ttl": {
"N": "1777475668"
},
"processed_at": {
"N": "1777389268"
},
"event_id": {
"S": "4f74c998-6158-4393-a5b1-05664d6ad1d8"
}
}
]Receive message and inspect body
source /app/build/queues.env # look at one message body without --query to avoid the cli serialization issue aws sqs receive-message --queue-url "$CREATED_URL" --max-number-of-messages 1 --visibility-timeout 0 > /tmp/msg.json 2>&1 || true cat /tmp/msg.json echo "---" # extract body content if present jq '.Messages[0].Body | fromjson' /tmp/msg.json 2>&1 || true
aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str' --- jq: parse error: Invalid numeric literal at line 2, column 4 [stdout] aws: [ERROR]: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 2): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str' --- jq: parse error: Invalid numeric literal at line 2, column 4
Receive message via boto3 to inspect body
source /app/build/queues.env
python3 - <<EOF
import boto3, json, os
os.environ.setdefault("AWS_ENDPOINT_URL", "http://localstack:4566")
sqs = boto3.client("sqs")
url = "$CREATED_URL"
resp = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=1, VisibilityTimeout=0)
msgs = resp.get("Messages", [])
print("count:", len(msgs))
for m in msgs:
body = json.loads(m["Body"])
print("--- body ---")
print(json.dumps(body, indent=2))
EOFExit code 1
Traceback (most recent call last):
File "<stdin>", line 5, in <module>
File "/opt/venv/lib/python3.12/site-packages/botocore/client.py", line 569, in _api_call
return self._make_api_call(operation_name, kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/venv/lib/python3.12/site-packages/botocore/client.py", line 1023, in _make_api_call
raise error_class(parsed_response, operation_name)
botocore.exceptions.ClientError: An error occurred (InternalError) when calling the ReceiveMessage operation (reached max retries: 4): exception while calling sqs.ReceiveMessage: string indices must be integers, not 'str'
[error] tool reported failureInspect queue attributes and test DLQ receive
source /app/build/queues.env # check queue attrs echo "--- main queue attrs ---" aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names All echo "--- try receive on the DLQ (no Policy/Redrive set) ---" aws sqs receive-message --queue-url "$DLQ_CREATED_URL" --max-number-of-messages 1 --wait-time-seconds 1 || echo "(empty)"
--- main queue attrs ---
{
"Attributes": {
"CreatedTimestamp": "1777389043",
"DelaySeconds": "0",
"LastModifiedTimestamp": "1777389043",
"MaximumMessageSize": "1048576",
"MessageRetentionPeriod": "345600",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created",
"ReceiveMessageWaitTimeSeconds": "0",
"VisibilityTimeout": "30",
"SqsManagedSseEnabled": "true",
"KmsMasterKeyId": "a378d809-0047-4032-a731-a97d25d2ceb5",
"RedrivePolicy": "\"{\\\"deadLetterTargetArn\\\":\\\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"",
"Policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"AllowEventBridgeRule\",\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"events.amazonaws.com\"\n },\n \"Action\": \"sqs:SendMessage\",\n \"Resource\": \"arn:aws:sqs:us-east-1:000000000000:orders-created\",\n \"Condition\": {\n \"ArnEquals\": {\n \"aws:SourceArn\": \"arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created\"\n }\n }\n }\n ]\n}\n",
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesDelayed": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
}
--- try receive on the DLQ (no Policy/Redrive set) ---
[stdout]
--- main queue attrs ---
{
"Attributes": {
"CreatedTimestamp": "1777389043",
"DelaySeconds": "0",
"LastModifiedTimestamp": "1777389043",
"MaximumMessageSize": "1048576",
"MessageRetentionPeriod": "345600",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created",
"ReceiveMessageWaitTimeSeconds": "0",
"VisibilityTimeout": "30",
"SqsManagedSseEnabled": "true",
"KmsMasterKeyId": "a378d809-0047-4032-a731-a97d25d2ceb5",
"RedrivePolicy": "\"{\\\"deadLetterTargetArn\\\":\\\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"",
"Policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"AllowEventBridgeRule\",\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"events.amazonaws.com\"\n },\n \"Action\": \"sqs:SendMessage\",\n \"Resource\": \"arn:aws:sqs:us-east-1:000000000000:orders-created\",\n \"Condition\": {\n \"ArnEquals\": {\n \"aws:SourceArn\": \"arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created\"\n }\n }\n }\n ]\n}\n",
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesDelayed": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
}
--- try receive on the DLQ (no Policy/Redrive set) ---Fix RedrivePolicy double-encoding
source /app/build/queues.env
# write redrive policy as a string value within an attributes file
jq -n --arg arn "$DLQ_CREATED_ARN" '{RedrivePolicy: ({deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring)}' > /tmp/created_redrive.json
jq -n --arg arn "$DLQ_CANCELLED_ARN" '{RedrivePolicy: ({deadLetterTargetArn:$arn, maxReceiveCount:"3"} | tostring)}' > /tmp/cancelled_redrive.json
cat /tmp/created_redrive.json
echo "---"
aws sqs set-queue-attributes --queue-url "$CREATED_URL" --attributes file:///tmp/created_redrive.json
aws sqs set-queue-attributes --queue-url "$CANCELLED_URL" --attributes file:///tmp/cancelled_redrive.json
echo "--- post-fix attrs ---"
aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names RedrivePolicy{
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
}
---
--- post-fix attrs ---
{
"Attributes": {
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
}
}
[stdout]
{
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
}
---
--- post-fix attrs ---
{
"Attributes": {
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"3\"}"
}
}Retry ReceiveMessage after RedrivePolicy fix
source /app/build/queues.env aws sqs receive-message --queue-url "$CREATED_URL" --max-number-of-messages 5 --visibility-timeout 0 --wait-time-seconds 2
{
"Messages": [
{
"MessageId": "2fc7d9db-60ff-4ea9-9e0a-f9d5ef359f17",
"ReceiptHandle": "NGM0Njc0NjMtMDJiNi00YzhhLThmNWQtNTQ2NmE3MWUwODhmIGFybjphd3M6c3FzOnVzLWVhc3QtMTowMDAwMDAwMDAwMDA6b3JkZXJzLWNyZWF0ZWQgMmZjN2Q5ZGItNjBmZi00ZWE5LTllMGEtZjlkNWVmMzU5ZjE3IDE3NzczODkzNTYuMjE0NTY3NA==",
"MD5OfBody": "b698a0b3f56c16a49a96372d3536ebc9",
"Body": "{\"version\":\"0\",\"id\":\"6ba4c9d5-d08a-423b-8d4f-67b82241d2ab\",\"detail-type\":\"OrderCreated\",\"source\":\"outbox.relay\",\"account\":\"000000000000\",\"time\":\"2026-04-28T15:14:28Z\",\"region\":\"us-east-1\",\"resources\":[],\"detail\":{\"event_id\":\"4f74c998-6158-4393-a5b1-05664d6ad1d8\",\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"created_at\":\"2026-04-28T15:14:26.861028+00:00\",\"payload\":{\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"customer_id\":\"c-42\",\"total_cents\":1999,\"currency\":\"USD\"}}}"
}
]
}
[stdout]
{
"Messages": [
{
"MessageId": "2fc7d9db-60ff-4ea9-9e0a-f9d5ef359f17",
"ReceiptHandle": "NGM0Njc0NjMtMDJiNi00YzhhLThmNWQtNTQ2NmE3MWUwODhmIGFybjphd3M6c3FzOnVzLWVhc3QtMTowMDAwMDAwMDAwMDA6b3JkZXJzLWNyZWF0ZWQgMmZjN2Q5ZGItNjBmZi00ZWE5LTllMGEtZjlkNWVmMzU5ZjE3IDE3NzczODkzNTYuMjE0NTY3NA==",
"MD5OfBody": "b698a0b3f56c16a49a96372d3536ebc9",
"Body": "{\"version\":\"0\",\"id\":\"6ba4c9d5-d08a-423b-8d4f-67b82241d2ab\",\"detail-type\":\"OrderCreated\",\"source\":\"outbox.relay\",\"account\":\"000000000000\",\"time\":\"2026-04-28T15:14:28Z\",\"region\":\"us-east-1\",\"resources\":[],\"detail\":{\"event_id\":\"4f74c998-6158-4393-a5b1-05664d6ad1d8\",\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"created_at\":\"2026-04-28T15:14:26.861028+00:00\",\"payload\":{\"order_id\":\"o-1\",\"kind\":\"OrderCreated\",\"customer_id\":\"c-42\",\"total_cents\":1999,\"currency\":\"USD\"}}}"
}
]
}Invoke OrderCancelled and verify split routing
source /app/build/queues.env
PAYLOAD2=$(jq -c -n '{order_id:"o-2", kind:"OrderCancelled", customer_id:"c-42", reason:"changed_mind"}')
aws lambda invoke \
--function-name order-api \
--cli-binary-format raw-in-base64-out \
--payload "$PAYLOAD2" \
/tmp/resp2.json --query 'StatusCode' --output text
cat /tmp/resp2.json
echo
sleep 8
echo "--- queue counts ---"
aws sqs get-queue-attributes --queue-url "$CREATED_URL" \
--attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
--query 'Attributes' --output json
aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" \
--attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
--query 'Attributes' --output json
echo "--- orders-cancelled message ---"
aws sqs receive-message --queue-url "$CANCELLED_URL" --max-number-of-messages 5 --visibility-timeout 0 --wait-time-seconds 2 --query 'Messages[].Body' --output json | jq '.[] | fromjson'
echo "--- processed_events ---"
aws dynamodb scan --table-name processed_events --query 'Items[].event_id.S' --output json
echo "--- outbox rows ---"
aws dynamodb scan --table-name outbox --query 'Items[].{event_id: event_id.S, kind: kind.S, order_id: order_id.S}' --output json
echo "--- orders rows ---"
aws dynamodb scan --table-name orders --query 'Items[].{order_id: order_id.S, kind: kind.S}' --output json200
{"statusCode": 200, "body": "{\"order_id\": \"o-2\", \"event_id\": \"f43d0333-be8f-4ce2-a51a-ebf8bb386388\", \"kind\": \"OrderCancelled\"}"}
--- queue counts ---
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled message ---
{
"version": "0",
"id": "f3fc2426-edb1-4d3f-bdcb-301b270f2d1b",
"detail-type": "OrderCancelled",
"source": "outbox.relay",
"account": "000000000000",
"time": "2026-04-28T15:16:14Z",
"region": "us-east-1",
"resources": [],
"detail": {
"event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
"order_id": "o-2",
"kind": "OrderCancelled",
"created_at": "2026-04-28T15:16:12.233895+00:00",
"payload": {
"order_id": "o-2",
"kind": "OrderCancelled",
"customer_id": "c-42",
"reason": "changed_mind"
}
}
}
--- processed_events ---
[
"4f74c998-6158-4393-a5b1-05664d6ad1d8",
"f43d0333-be8f-4ce2-a51a-ebf8bb386388"
]
--- outbox rows ---
[
{
"event_id": "4f74c998-6158-4393-a5b1-05664d6ad1d8",
"kind": "OrderCreated",
"order_id": "o-1"
},
{
"event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
"kind": "OrderCancelled",
"order_id": "o-2"
}
]
--- orders rows ---
[
{
"order_id": "o-1",
"kind": "OrderCreated"
},
{
"order_id": "o-2",
"kind": "OrderCancelled"
}
]
[stdout]
200
{"statusCode": 200, "body": "{\"order_id\": \"o-2\", \"event_id\": \"f43d0333-be8f-4ce2-a51a-ebf8bb386388\", \"kind\": \"OrderCancelled\"}"}
--- queue counts ---
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}
{
"ApproximateNumberOfMessages": "1",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- orders-cancelled message ---
{
"version": "0",
"id": "f3fc2426-edb1-4d3f-bdcb-301b270f2d1b",
"detail-type": "OrderCancelled",
"source": "outbox.relay",
"account": "000000000000",
"time": "2026-04-28T15:16:14Z",
"region": "us-east-1",
"resources": [],
"detail": {
"event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
"order_id": "o-2",
"kind": "OrderCancelled",
"created_at": "2026-04-28T15:16:12.233895+00:00",
"payload": {
"order_id": "o-2",
"kind": "OrderCancelled",
"customer_id": "c-42",
"reason": "changed_mind"
}
}
}
--- processed_events ---
[
"4f74c998-6158-4393-a5b1-05664d6ad1d8",
"f43d0333-be8f-4ce2-a51a-ebf8bb386388"
]
--- outbox rows ---
[
{
"event_id": "4f74c998-6158-4393-a5b1-05664d6ad1d8",
"kind": "OrderCreated",
"order_id": "o-1"
},
{
"event_id": "f43d0333-be8f-4ce2-a51a-ebf8bb386388",
"kind": "OrderCancelled",
"order_id": "o-2"
}
]
--- orders rows ---
[
{
"order_id": "o-1",
"kind": "OrderCreated"
},
{
"order_id": "o-2",
"kind": "OrderCancelled"
}
]No file changes, the agent left the workspace untouched.
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/J_14WEX8_2SORwXI6D9ID/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 20 items
../tests/test_outputs.py::test_01_cmk_alias_exists_and_enabled PASSED [ 5%]
../tests/test_outputs.py::test_02_orders_table_exists PASSED [ 10%]
../tests/test_outputs.py::test_03_outbox_table_exists PASSED [ 15%]
../tests/test_outputs.py::test_04_processed_events_table_exists PASSED [ 20%]
../tests/test_outputs.py::test_05_custom_event_bus_exists PASSED [ 25%]
../tests/test_outputs.py::test_06_orders_table_encrypted_with_cmk PASSED [ 30%]
../tests/test_outputs.py::test_07_outbox_table_encrypted_with_cmk PASSED [ 35%]
../tests/test_outputs.py::test_08_outbox_stream_is_new_and_old_images PASSED [ 40%]
../tests/test_outputs.py::test_09_processed_events_ttl_enabled PASSED [ 45%]
../tests/test_outputs.py::test_10_both_rule_patterns_use_array_wrap_and_disjoint_types PASSED [ 50%]
../tests/test_outputs.py::test_11_each_rule_targets_only_its_intended_queue PASSED [ 55%]
../tests/test_outputs.py::test_12_both_main_queues_encrypted_with_cmk PASSED [ 60%]
../tests/test_outputs.py::test_13_main_queues_have_redrive_to_distinct_dlqs PASSED [ 65%]
../tests/test_outputs.py::test_14_queue_policies_scope_events_with_source_arn PASSED [ 70%]
../tests/test_outputs.py::test_15_relay_esm_has_batch_item_failures_on_outbox_stream PASSED [ 75%]
../tests/test_outputs.py::test_16_relay_role_is_scoped_not_wildcard PASSED [ 80%]
../tests/test_outputs.py::test_17_order_api_role_scoped_to_orders_and_outbox_only PASSED [ 85%]
../tests/test_outputs.py::test_18_e2e_order_created_routes_to_created_queue_only PASSED [ 90%]
../tests/test_outputs.py::test_19_e2e_order_cancelled_routes_to_cancelled_queue_only PASSED [ 95%]
../tests/test_outputs.py::test_20_e2e_idempotent_duplicate_does_not_double_fanout PASSED [100%]
=============================== warnings summary ===============================
test_outputs.py: 90 warnings
/root/.cache/uv/archive-v0/J_14WEX8_2SORwXI6D9ID/lib/python3.12/site-packages/botocore/auth.py:424: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
datetime_now = datetime.datetime.utcnow()
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED ../tests/test_outputs.py::test_01_cmk_alias_exists_and_enabled
PASSED ../tests/test_outputs.py::test_02_orders_table_exists
PASSED ../tests/test_outputs.py::test_03_outbox_table_exists
PASSED ../tests/test_outputs.py::test_04_processed_events_table_exists
PASSED ../tests/test_outputs.py::test_05_custom_event_bus_exists
PASSED ../tests/test_outputs.py::test_06_orders_table_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_07_outbox_table_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_08_outbox_stream_is_new_and_old_images
PASSED ../tests/test_outputs.py::test_09_processed_events_ttl_enabled
PASSED ../tests/test_outputs.py::test_10_both_rule_patterns_use_array_wrap_and_disjoint_types
PASSED ../tests/test_outputs.py::test_11_each_rule_targets_only_its_intended_queue
PASSED ../tests/test_outputs.py::test_12_both_main_queues_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_13_main_queues_have_redrive_to_distinct_dlqs
PASSED ../tests/test_outputs.py::test_14_queue_policies_scope_events_with_source_arn
PASSED ../tests/test_outputs.py::test_15_relay_esm_has_batch_item_failures_on_outbox_stream
PASSED ../tests/test_outputs.py::test_16_relay_role_is_scoped_not_wildcard
PASSED ../tests/test_outputs.py::test_17_order_api_role_scoped_to_orders_and_outbox_only
PASSED ../tests/test_outputs.py::test_18_e2e_order_created_routes_to_created_queue_only
PASSED ../tests/test_outputs.py::test_19_e2e_order_cancelled_routes_to_cancelled_queue_only
PASSED ../tests/test_outputs.py::test_20_e2e_idempotent_duplicate_does_not_double_fanout
======================= 20 passed, 90 warnings in 23.09s =======================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_7c4b7ca31b2c4dea. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_7c4b7ca31b2c4dea · verifier authoritative; classifier explanatory.