Skip to content

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:

  1. Generates an SQS DLQ queue (queue_lambda_<name>_dlq.tf) with a visibility timeout of 6x the Lambda timeout
  2. Configures async invoke config on the Lambda with the DLQ as the on-failure destination
  3. Attaches an IAM policy granting sqs:SendMessage on the DLQ to the Lambda's execution role
  4. 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:

typescript
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:

typescript
defineLambda({ deadLetterQueue: true, retryAttempts: 0 })

Setting retryAttempts: 0 means the event goes to the DLQ on the first failure with no retries.

Configuration options

OptionTypeDefaultDescription
deadLetterQueueboolean | { targetArn?: string }true = auto-generate SQS DLQ
retryAttemptsnumber2Max 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:

typescript
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)

hcl
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:

hcl
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 DLQ
  • aws_iam_role_policy (DLQSend) — grants sqs:SendMessage so 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 typeInvocationDLQ supported
S3 triggerAsyncYes
EventBridgeAsyncYes
ScheduledAsyncYes
StandaloneDependsYes (if invoked async)
SQS consumerSync (polled)No — SQS has its own DLQ via maxReceiveCount
API GatewaySyncNo
WebSocketSyncNo

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.

typescript
// 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 with enable_dead_letter_queue = true and dead_letter_target_arn pointing to the DLQ

Monitoring DLQ messages

Failed events accumulate in the DLQ as standard SQS messages. Use the AWS CLI to inspect them:

bash
# 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 1

The DLQ retains messages for 14 days by default (dlq_retention_seconds = 1209600).

Next steps