
Building a Resilient AWS SQS and Lambda Pipeline with .NET 10 and Terraform
Building a Resilient AWS SQS and Lambda Pipeline with .NET 10 and Terraform
Building a Resilient AWS SQS and Lambda Pipeline with .NET 10 and Terraform
A common mistake when building applications with Amazon SQS and AWS Lambda is treating batch processing as an “all-or-nothing” operation.
Suppose Lambda receives a batch of 10 messages and one message fails. By default, the entire batch can become visible again after the SQS visibility timeout, causing successfully processed messages to be processed again. This can increase unnecessary retries, duplicate processing, and compute consumption.
AWS Lambda provides partial batch responses to solve this problem. By enabling
ReportBatchItemFailures, the function can identify only the messages that failed, allowing Lambda to retry those messages while successfully processed messages are acknowledged.In this hands-on project, we will build a resilient SQS-to-Lambda processing pipeline using:
- Terraform for Infrastructure as Code
- AWS Lambda with .NET 10
- Amazon SQS
- SQS Dead Letter Queue (DLQ)
- Partial batch failure reporting
- AWS CLI
- Amazon CloudWatch Logs
We will also deliberately introduce a “poison pill” message and observe how the system isolates, retries, and eventually moves the failed message to the DLQ.
Architecture
The architecture consists of:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌─────────────────────┐
│ Amazon SQS │
│ Main Queue │
└──────────┬──────────┘
│
Batch of messages
│
▼
┌─────────────────────┐
│ AWS Lambda │
│ .NET 10 │
└──────────┬──────────┘
│
┌────────────┴────────────┐
│ │
Success Failure
│ │
▼ ▼
Deleted Retry message
│
Retry threshold reached
│
▼
┌─────────────────┐
│ SQS DLQ │
└─────────────────┘
The key idea is that a failure in one message should not unnecessarily cause successfully processed messages to be retried.
Step 1: Project Structure
Create a workspace that separates the Lambda application from the Terraform infrastructure:
1
2
mkdir -p aws-sqs-resilient-dotnet/src aws-sqs-resilient-dotnet/infra
cd aws-sqs-resilient-dotnet
The resulting structure will look like:
1
2
3
4
5
6
7
8
9
aws-sqs-resilient-dotnet/
├── src/
│ └── SqsResilientProcessor/
│ ├── Function.cs
│ └── SqsResilientProcessor.csproj
└── infra/
├── main.tf
├── variables.tf
└── outputs.tf
Step 2: Build the Lambda Function with .NET 10
AWS Lambda currently provides a managed .NET 10 runtime using Amazon Linux 2023, so we can deploy the function using the native
dotnet10 runtime instead of creating a custom runtime.Step 2.1: Create the .NET Project
Navigate to the source directory:
1
cd src
Install the AWS Lambda templates if you haven't already:
1
dotnet new install Amazon.Lambda.Templates
Create the SQS Lambda project:
1
2
dotnet new lambda.SQS -n SqsResilientProcessor
cd SqsResilientProcessor
Install the required packages:
1
2
3
dotnet add package Amazon.Lambda.Core
dotnet add package Amazon.Lambda.SQSEvents
dotnet add package Amazon.Lambda.Serialization.SystemTextJson
Step 2.2: Configure the Project
Open
SqsResilientProcessor.csproj and configure it for .NET 10:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Amazon.Lambda.Core" Version="3.3.0" />
<PackageReference Include="Amazon.Lambda.SQSEvents" Version="3.0.1" />
<PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="3.0.1" />
</ItemGroup>
</Project>
Note: AWS Lambda provides.NET 10as a managed runtime, identified asdotnet10, on Amazon Linux 2023.
Step 3: Implement Partial Batch Processing
Replace
Function.cs with:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using Amazon.Lambda.Core;
using Amazon.Lambda.SQSEvents;
namespace SqsResilientProcessor;
public class Function
{
public SQSBatchResponse FunctionHandler(
SQSEvent sqsEvent,
ILambdaContext context)
{
var batchItemFailures =
new List<SQSBatchResponse.BatchItemFailure>();
context.Logger.LogInformation(
$"Starting batch processing for {sqsEvent.Records.Count} message(s).");
foreach (var message in sqsEvent.Records)
{
try
{
ProcessMessage(message, context);
}
catch (Exception ex)
{
context.Logger.LogError(
$"Failed to process message {message.MessageId}: {ex.Message}");
batchItemFailures.Add(
new SQSBatchResponse.BatchItemFailure
{
ItemIdentifier = message.MessageId
});
}
}
context.Logger.LogInformation(
$"Batch completed. Total failures: {batchItemFailures.Count}.");
return new SQSBatchResponse(batchItemFailures);
}
private static void ProcessMessage(
SQSEvent.SQSMessage message,
ILambdaContext context)
{
// Simulate a poison-pill message.
if (message.Body.Contains(
"poison",
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Invalid or poisoned payload detected: {message.Body}");
}
context.Logger.LogInformation(
$"Message {message.MessageId} processed successfully: {message.Body}");
}
}
How the code works
Each message is processed independently inside a
try/catch block.If processing succeeds:
1
2
3
4
5
6
7
Message
↓
Process successfully
↓
Not included in batchItemFailures
↓
Lambda/SQS considers it successfully processed
If processing fails:
1
2
3
4
5
6
7
Message
↓
Exception
↓
MessageId added to batchItemFailures
↓
Only that message is retried
AWS requires the function response to identify failed messages using their message IDs when
ReportBatchItemFailures is enabled.Step 4: Package the Lambda Function
Build the application:
1
dotnet publish -c Release -r linux-x64 --self-contained false -o ./publish
The deployment package will be generated inside:
1
src/SqsResilientProcessor/publish/
Create the ZIP package:
1
2
3
cd publish
zip -r ../../../infra/lambda.zip .
cd ../../..
Verify that the package exists:
1
ls -lh infra/lambda.zip
Step 5: Provision the Infrastructure with Terraform
Terraform will create:
- Main SQS queue
- Dead Letter Queue
- IAM execution role
- Lambda function
- SQS event source mapping
- Partial batch failure configuration
AWS recommends setting the SQS visibility timeout to at least six times the Lambda function timeout.
In our example:
1
2
Lambda timeout = 10 seconds
SQS visibility = 60 seconds
Step 5.1: Create variables.tf
Navigate to the infrastructure directory:
1
cd infra
Create the file:
1
touch variables.tf
Add:
1
2
3
4
5
6
7
8
9
10
11
variable "aws_region" {
type = string
description = "Target AWS region"
default = "us-east-1"
}
variable "environment" {
type = string
description = "Environment identifier"
default = "dev"
}
Step 5.2: Create main.tf
Create the file:
1
touch main.tf
Add:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# -------------------------------------------------
# 1. Dead Letter Queue
# -------------------------------------------------
resource "aws_sqs_queue" "dlq" {
name = "orders-queue-dlq-${var.environment}"
message_retention_seconds = 1209600
}
# -------------------------------------------------
# 2. Main SQS Queue
# -------------------------------------------------
resource "aws_sqs_queue" "main" {
name = "orders-queue-${var.environment}"
# Lambda timeout = 10 seconds
# Visibility timeout = 6 x 10 = 60 seconds
visibility_timeout_seconds = 60
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = 3
})
}
# -------------------------------------------------
# 3. Lambda IAM Role
# -------------------------------------------------
resource "aws_iam_role" "lambda_exec" {
name = "sqs_processor_lambda_role_${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# Basic Lambda logging permissions
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda_exec.name
policy_arn =
"arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# SQS permissions
resource "aws_iam_role_policy_attachment" "lambda_sqs" {
role = aws_iam_role.lambda_exec.name
policy_arn =
"arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
}
# -------------------------------------------------
# 4. Lambda Function
# -------------------------------------------------
resource "aws_lambda_function" "processor" {
filename = "lambda.zip"
function_name = "SqsResilientProcessor-${var.environment}"
role = aws_iam_role.lambda_exec.arn
handler = "SqsResilientProcessor::SqsResilientProcessor.Function::FunctionHandler"
runtime = "dotnet10"
timeout = 10
memory_size = 256
source_code_hash = filebase64sha256("lambda.zip")
}
# -------------------------------------------------
# 5. SQS Event Source Mapping
# -------------------------------------------------
resource "aws_lambda_event_source_mapping" "sqs_trigger" {
event_source_arn = aws_sqs_queue.main.arn
function_name = aws_lambda_function.processor.arn
batch_size = 10
function_response_types = [
"ReportBatchItemFailures"
]
}
The important configuration is:
1
2
3
function_response_types = [
"ReportBatchItemFailures"
]
Without this configuration, Lambda does not process the partial failure response from the function.
Step 5.3: Create outputs.tf
Create:
1
touch outputs.tf
Add:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
output "main_queue_url" {
description = "URL of the primary SQS queue"
value = aws_sqs_queue.main.id
}
output "dlq_url" {
description = "URL of the Dead Letter Queue"
value = aws_sqs_queue.dlq.id
}
output "lambda_function_name" {
description = "Name of the Lambda function"
value = aws_lambda_function.processor.function_name
}
Step 5.4: Deploy
Initialize Terraform:
1
terraform init
Review the deployment:
1
terraform plan
Apply the infrastructure:
1
terraform apply -auto-approve
At the end of the deployment, Terraform should display:
1
2
3
main_queue_url
dlq_url
lambda_function_name
You can also verify the resources in the AWS Console.
Amazon SQS
Navigate to:
1
2
AWS Console
→ Amazon SQS
You should see:
1
2
orders-queue-dev
orders-queue-dlq-dev
AWS Lambda
Navigate to:
1
2
3
4
AWS Console
→ Lambda
→ Functions
→ SqsResilientProcessor-dev
Step 6: Validate the System with AWS CLI
Export the Terraform outputs:
1
2
3
4
5
export MAIN_QUEUE_URL=$(terraform output -raw main_queue_url)
export DLQ_URL=$(terraform output -raw dlq_url)
export LAMBDA_NAME=$(terraform output -raw lambda_function_name)
Verify them:
1
2
3
echo "Main Queue: $MAIN_QUEUE_URL"
echo "DLQ: $DLQ_URL"
echo "Lambda: $LAMBDA_NAME"
Scenario 1: Process a Healthy Message
Send a normal message:
1
2
3
aws sqs send-message \
--queue-url "$MAIN_QUEUE_URL" \
--message-body '{"orderId": "ORD-1001", "amount": 150.00}'
View the Lambda logs:
1
aws logs tail "/aws/lambda/$LAMBDA_NAME" --since 2m
You should see a log similar to:
1
2
3
4
5
6
Starting batch processing for 1 message(s).
Message <message-id> processed successfully:
{"orderId": "ORD-1001", "amount": 150.00}
Batch completed. Total failures: 0.
The message has been successfully processed.
Scenario 2: Test Partial Batch Failure
Now we will create a batch containing:
- One valid message
- One poison-pill message
Send both messages:
1
2
3
4
5
6
7
8
9
10
11
12
aws sqs send-message-batch \
--queue-url "$MAIN_QUEUE_URL" \
--entries '[
{
"Id": "msg1",
"MessageBody": "{\"orderId\": \"ORD-1002\", \"status\": \"valid\"}"
},
{
"Id": "msg2",
"MessageBody": "{\"orderId\": \"ORD-9999\", \"status\": \"poison\"}"
}
]'
View the Lambda logs:
1
aws logs tail "/aws/lambda/$LAMBDA_NAME" --since 2m
The Lambda function processes the two messages independently.
Conceptually:
1
2
3
4
5
6
7
8
9
10
11
12
13
ORD-1002
↓
Success
↓
Acknowledged
ORD-9999
↓
Exception
↓
Added to batchItemFailures
↓
Retried
This is the key behavior provided by
ReportBatchItemFailures: successfully processed messages do not need to be retried simply because another message in the batch failed.Scenario 3: Retry Exhaustion and DLQ
Our Terraform configuration contains:
1
2
3
4
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = 3
})
The poison message continues to fail.
After the configured receive threshold is reached, SQS moves the message to the DLQ.
Check the main queue:
1
2
3
4
5
aws sqs get-queue-attributes \
--queue-url "$MAIN_QUEUE_URL" \
--attribute-names \
ApproximateNumberOfMessages \
ApproximateNumberOfMessagesNotVisible
Check the DLQ:
1
2
3
aws sqs get-queue-attributes \
--queue-url "$DLQ_URL" \
--attribute-names ApproximateNumberOfMessages
Eventually, you should see the failed message represented in the DLQ.
For production workloads, choosemaxReceiveCountaccording to the application's failure characteristics and recovery time. AWS currently recommends considering at least 5 retries when configuring an SQS event source for Lambda.
Step 7: Inspect the Dead Letter Queue
Retrieve the failed message:
1
2
3
4
aws sqs receive-message \
--queue-url "$DLQ_URL" \
--max-number-of-messages 1 \
--attribute-names All
You should see the failed payload, for example:
1
2
3
{
"Body": "{\"orderId\": \"ORD-9999\", \"status\": \"poison\"}"
}
This allows operators to investigate the failed message before deciding whether it should be discarded, corrected, or reprocessed.
Step 8: Redrive Messages from the DLQ
Once the underlying problem has been fixed, SQS supports moving messages from a DLQ back to their source queue using a native message move task.
First, retrieve the source queue ARN:
1
2
3
4
5
SOURCE_ARN=$(aws sqs get-queue-attributes \
--queue-url "$MAIN_QUEUE_URL" \
--attribute-names QueueArn \
--query "Attributes.QueueArn" \
--output text)
Retrieve the DLQ ARN:
1
2
3
4
5
DLQ_ARN=$(aws sqs get-queue-attributes \
--queue-url "$DLQ_URL" \
--attribute-names QueueArn \
--query "Attributes.QueueArn" \
--output text)
Start the redrive:
1
2
3
aws sqs start-message-move-task \
--source-arn "$DLQ_ARN" \
--destination-arn "$SOURCE_ARN"
Track the task:
1
2
aws sqs list-message-move-tasks \
--source-arn "$DLQ_ARN"
The message can then return to the source queue for another processing attempt.
Step 9: Monitor the System
CloudWatch Logs provide visibility into individual Lambda executions.
You can monitor:
1
aws logs tail "/aws/lambda/$LAMBDA_NAME" --since 10m
For production systems, useful SQS metrics include:
NumberOfMessagesDeletedApproximateAgeOfOldestMessageApproximateNumberOfMessagesVisibleApproximateNumberOfMessagesNotVisible
AWS specifically recommends monitoring SQS metrics such as
NumberOfMessagesDeleted and ApproximateAgeOfOldestMessage when validating partial batch processing.Step 10: Clean Up the Resources
AWS resources created for experiments should be removed when they are no longer required.
From the
infra directory:1
terraform destroy -auto-approve
If a CloudWatch log group remains, you can remove it with:
1
2
aws logs delete-log-group \
--log-group-name "/aws/lambda/SqsResilientProcessor-dev"
What This Project Demonstrates
This project demonstrates several important serverless architecture concepts:
1. Partial Batch Processing
Instead of retrying an entire batch, Lambda can retry only the failed messages when
ReportBatchItemFailures is enabled.2. Dead Letter Queues
A DLQ provides a separate destination for messages that continue to fail after the configured retry threshold.
3. Infrastructure as Code
Terraform allows the entire architecture to be defined and reproduced through code rather than manually creating resources through the AWS Console.
4. Failure Isolation
A single poison-pill message does not have to repeatedly force successfully processed messages through the same processing path.
5. Operational Visibility
AWS CLI and CloudWatch Logs make it possible to observe message processing, failures, retries, and DLQ behavior.
6. Message Redrive
SQS provides a native mechanism for moving messages from a DLQ back to a source queue after the underlying problem has been resolved.
Conclusion
Reliable asynchronous processing is not just about successfully handling the happy path. A production-oriented architecture must also define what happens when individual messages fail.
By combining Amazon SQS, AWS Lambda, partial batch responses, Dead Letter Queues, Terraform, CloudWatch, and SQS redrive, we can build a processing pipeline that isolates failures instead of unnecessarily retrying successful work.
The most important configuration in this project is:
1
2
3
function_response_types = [
"ReportBatchItemFailures"
]
This enables Lambda to understand the
batchItemFailures response and retry only the messages identified as failed.The result is a more resilient processing model:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SQS
│
▼
Lambda Batch
│
┌──────┴──────┐
│ │
Success Failure
│ │
▼ ▼
Deleted Retry
│
Retry threshold
│
▼
DLQ
│
▼
Redrive
This pattern is particularly useful when building asynchronous workloads where individual messages can fail independently and need controlled retry and recovery behavior.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article