Dead Letter Queues
When a Lambda function fails to process an asynchronous invocation (S3 events, EventBridge, scheduled triggers), AWS retries it up to 2 times by default, then silently discards the event. Dead letter queues (DLQs) capture these failed events so you can investigate and reprocess them.
Mantle supports DLQs through defineLambda() configuration and automatic infrastructure generation.
How it works
When you set deadLetterQueue: true on a Lambda, the framework:
- Generates an SQS DLQ queue (
queue_lambda_<name>_dlq.tf) with a visibility timeout of 6x the Lambda timeout - Configures async invoke config on the Lambda with the DLQ as the on-failure destination
- Attaches an IAM policy granting
sqs:SendMessageon the DLQ to the Lambda's execution role - Sets retry behavior via
retryAttempts(defaults to 2, configurable 0-2)
Async Event → Lambda (retries up to N times) → On failure → DLQ (SQS)Adding a DLQ to a Lambda
Add deadLetterQueue: true to your defineLambda() call:
import { defineLambda, defineS3Handler } from '@mantleframework/core'
defineLambda({ deadLetterQueue: true })
export const handler = defineS3Handler(async (event, context) => {
// Process S3 events...
// If this fails after retries, the event lands in the DLQ
})To also control retry attempts:
defineLambda({ deadLetterQueue: true, retryAttempts: 0 })Setting retryAttempts: 0 means the event goes to the DLQ on the first failure with no retries.
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
deadLetterQueue | boolean | { targetArn?: string } | — | true = auto-generate SQS DLQ |
retryAttempts | number | 2 | Max async retry attempts (0-2) before sending to DLQ |
Using an existing queue
If you already have a DLQ (e.g., a shared queue across Lambdas), pass the ARN directly:
defineLambda({
deadLetterQueue: { targetArn: 'arn:aws:sqs:us-west-2:123456789:my-shared-dlq' }
})Generated infrastructure
Running mantle generate infra produces two files for a Lambda with deadLetterQueue: true:
DLQ queue file (queue_lambda_<name>_dlq.tf)
module "queue_lambda_s3object_created_dlq" {
source = "../../mantle/modules/queue"
queue_name = "lambda_s3object_created_dlq"
name_prefix = module.core.name_prefix
tags = module.core.common_tags
visibility_timeout_seconds = 180
enable_dlq_alarm = false
}The visibility timeout is automatically set to 6x the Lambda timeout (default 30s x 6 = 180s), following AWS best practices.
Lambda file additions
The Lambda module call gains two parameters:
module "lambda_s3object_created" {
source = "../../mantle/modules/lambda"
# ... standard parameters ...
dead_letter_target_arn = module.queue_lambda_s3object_created_dlq.queue_arn
enable_dead_letter_queue = true
}The enable_dead_letter_queue boolean is required because OpenTofu's count expressions must be evaluable at plan time. The DLQ queue ARN is a runtime value (the queue is being created in the same apply), so a simple dead_letter_target_arn != "" check would fail.
What the Lambda module creates
The modules/lambda module handles the AWS resources:
aws_lambda_function_event_invoke_config— configures the on-failure destination pointing to the DLQaws_iam_role_policy(DLQSend) — grantssqs:SendMessageso the Lambda role can write to the DLQ- Retry config — sets
maximum_retry_attempts(defaults to 2)
Which Lambda types support DLQs
DLQs apply to asynchronously invoked Lambdas. API Gateway invocations are synchronous and don't use DLQs.
| Lambda type | Invocation | DLQ supported |
|---|---|---|
| S3 trigger | Async | Yes |
| EventBridge | Async | Yes |
| Scheduled | Async | Yes |
| Standalone | Depends | Yes (if invoked async) |
| SQS consumer | Sync (polled) | No — SQS has its own DLQ via maxReceiveCount |
| API Gateway | Sync | No |
| WebSocket | Sync | No |
For SQS consumers, the queue module already creates a built-in DLQ with maxReceiveCount: 3. Messages that fail processing 3 times move to the queue's own DLQ automatically.
Production examples
OfflineMediaDownloader — S3ObjectCreated
The S3ObjectCreated Lambda processes video uploads. When a file is uploaded to S3, this Lambda updates database records and queues push notifications. If it fails (e.g., database unavailable), the S3 event is preserved in the DLQ for reprocessing.
// src/lambdas/s3/S3ObjectCreated/index.ts
import { defineLambda, defineS3Handler } from '@mantleframework/core'
import { SqsQueueUrl } from '@mantleframework/core'
defineLambda({ deadLetterQueue: true, bind: { SNS_QUEUE_URL: 'SendPushNotification' } })
export const handler = defineS3Handler(async (event, context) => {
for (const record of event.Records) {
const file = await getFileByFilename(record.s3.object.key)
await updateFileStatus(file, 'ready')
await sendPushNotification(file)
}
})Generated infra creates:
queue_lambda_s3object_created_dlq.tf— DLQ with 180s visibility timeout (30s Lambda timeout x 6)lambda_s3object_created.tf— Lambda withenable_dead_letter_queue = trueanddead_letter_target_arnpointing to the DLQ
Monitoring DLQ messages
Failed events accumulate in the DLQ as standard SQS messages. Use the AWS CLI to inspect them:
# Check queue depth
aws sqs get-queue-attributes \
--queue-url "https://sqs.us-west-2.amazonaws.com/ACCOUNT/staging-lambda_s3object_created_dlq" \
--attribute-names ApproximateNumberOfMessages
# Receive a message (for inspection, does not delete)
aws sqs receive-message \
--queue-url "https://sqs.us-west-2.amazonaws.com/ACCOUNT/staging-lambda_s3object_created_dlq" \
--max-number-of-messages 1The DLQ retains messages for 14 days by default (dlq_retention_seconds = 1209600).
Next steps
- Resource Binding — mapping env vars to resources with
defineLambda({ bind }) - S3 Triggers — handler pattern for S3 events
- SQS Queues — SQS consumer pattern (has its own built-in DLQ)
- Infrastructure — full generator reference
- Observability — logging and tracing for debugging failed invocations