Het probleem: wat er gebeurt met berichten die mislukken

Asynchrone berichtensystemen zoals SQS, SNS, Kafka en EventBridge gebruiken het patroon minimaal één keer bezorgen: een bericht wordt minstens één keer afgeleverd, maar het kan meerdere keren worden afgeleverd (duplicaten in geval van nieuwe poging). Dit creëert twee kritieke scenario's:

  1. Tijdelijke fouten: De downstream-service is tijdelijk niet beschikbaar. Automatisch opnieuw proberen lost het probleem op. Na enkele pogingen wordt het bericht correct verwerkt.
  2. Blijvende fouten (gifpil): het bericht is verkeerd opgemaakt en bevat gegevens die zakelijke invarianten schenden, of de code van de consument bevat een bug. Opnieuw proberen helpt niet: het bericht zal voor onbepaalde tijd blijven mislukken, waardoor mogelijk middelen worden verbruikt het blokkeren van de verwerking van volgende berichten.

De DLQ lost het tweede scenario op: na een configureerbaar aantal mislukte pogingen (maxReceiveCount in SQS, MAX_RETRY_ATTEMPTS in Kafka), wordt het bericht verplaatst naar de wachtrij voor dode brieven waar het op gecontroleerde wijze kan worden geanalyseerd en herverwerkt.

DLQ: het Veerkrachtcontract

  • Geen gegevensverlies: Er worden geen berichten stilzwijgend verwijderd
  • Probleem isolatie: Gifpillen blokkeren geen goede berichten
  • Zichtbaarheid: Berichten in DLQ kunnen worden geïnspecteerd op foutopsporing
  • Gecontroleerde herverwerking: nadat het probleem is verholpen, worden de berichten opnieuw verwerkt

DLQ in Amazon SQS

In SQS is de DLQ eenvoudigweg een andere SQS-wachtrij die is geconfigureerd als bestemming voor berichten die overschrijden maxReceiveCount. Het mechanisme is gebaseerd op time-out voor zichtbaarheid: wanneer een consument een bericht ontvangt, wordt dit voor de duur "onzichtbaar" voor andere consumenten van de zichtbaarheidstime-out. Als het niet binnen die tijd wordt verwijderd (de consument is gefaald of gecrasht), SQS maakt het weer zichtbaar voor een nieuwe poging.

De teller ApproximateReceiveCount wordt bij elke ontvangst verhoogd. Wanneer het bereikt maxReceiveCount, verplaatst SQS het bericht naar de geconfigureerde DLQ.

# Configurazione DLQ per SQS con Terraform

# 1. Crea la DLQ (stessa tipologia della coda principale)
resource "aws_sqs_queue" "ordini_dlq" {
  name                       = "ordini-queue-dlq"
  message_retention_seconds  = 1209600  # 14 giorni (massimo SQS)
  visibility_timeout_seconds = 300      # 5 minuti per elaborare dalla DLQ

  # CloudWatch alarm sulla DLQ
  tags = {
    Environment = "production"
    Alert       = "critical"
  }
}

# 2. Crea la coda principale con redrive policy che punta alla DLQ
resource "aws_sqs_queue" "ordini" {
  name                       = "ordini-queue"
  visibility_timeout_seconds = 60       # 60s per elaborare ogni messaggio
  receive_wait_time_seconds  = 20       # long polling
  message_retention_seconds  = 345600   # 4 giorni

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.ordini_dlq.arn
    maxReceiveCount     = 3  # 3 tentativi falliti -> DLQ
  })
}

# 3. CloudWatch Alarm: alert quando la DLQ ha messaggi
resource "aws_cloudwatch_metric_alarm" "dlq_not_empty" {
  alarm_name          = "ordini-dlq-not-empty"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "1"
  metric_name         = "ApproximateNumberOfMessagesVisible"
  namespace           = "AWS/SQS"
  period              = "60"
  statistic           = "Sum"
  threshold           = "0"
  alarm_description   = "CRITICO: messaggi in DLQ ordini"

  dimensions = {
    QueueName = aws_sqs_queue.ordini_dlq.name
  }

  alarm_actions = [aws_sns_topic.alerts.arn]
}

Inspecteer en herverwerk door DLQ SQS

// DlqReprocessor.java - Riprocessa messaggi dalla DLQ SQS
import software.amazon.awssdk.services.sqs.*;
import software.amazon.awssdk.services.sqs.model.*;

public class SqsDlqReprocessor {

    private final SqsClient sqsClient;
    private final String dlqUrl;
    private final String mainQueueUrl;

    // Riprocessa tutti i messaggi dalla DLQ verso la coda principale
    public void reprocessAll() {
        int reprocessed = 0;
        List<Message> messages;

        do {
            messages = receiveMessages(dlqUrl, 10);

            for (Message message : messages) {
                try {
                    // Analizza il messaggio per capire il tipo di errore
                    System.out.printf("Riprocesso messaggio: id=%s, receiveCount=%s%n",
                        message.messageId(),
                        message.attributes().get(MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT)
                    );

                    // Rimanda alla coda principale (con delay opzionale)
                    sqsClient.sendMessage(
                        SendMessageRequest.builder()
                            .queueUrl(mainQueueUrl)
                            .messageBody(message.body())
                            .messageAttributes(message.messageAttributes())
                            .delaySeconds(0)
                            .build()
                    );

                    // Elimina dalla DLQ
                    sqsClient.deleteMessage(
                        DeleteMessageRequest.builder()
                            .queueUrl(dlqUrl)
                            .receiptHandle(message.receiptHandle())
                            .build()
                    );

                    reprocessed++;

                } catch (Exception e) {
                    System.err.println("Errore reprocessing: " + e.getMessage());
                    // Non eliminare: rimane in DLQ
                }
            }

        } while (!messages.isEmpty());

        System.out.printf("Reprocessing completato: %d messaggi rimandati%n", reprocessed);
    }

    private List<Message> receiveMessages(String queueUrl, int maxMessages) {
        return sqsClient.receiveMessage(
            ReceiveMessageRequest.builder()
                .queueUrl(queueUrl)
                .maxNumberOfMessages(maxMessages)
                .waitTimeSeconds(5)
                .attributeNames(QueueAttributeName.ALL)
                .build()
        ).messages();
    }
}

DLQ in AWS Lambda: functieniveau versus wachtrijniveau

In AWS Lambda kan DLQ op twee verschillende niveaus worden geconfigureerd met verschillende semantiek:

  • SQS-wachtrij DLQ: Geconfigureerd op de bron-SQS-wachtrij. Berichten worden verplaatst in DLQ wanneer SQS groter is dan de maxReceiveCount. Dit gebeurt Voor dat Lambda wordt aangeroepen. Dit is de aanbevolen configuratie voor Lambda + SQS.
  • Lambda-functie DLQ: geconfigureerd op de Lambda zelf (alleen voor asynchrone aanroepen, niet voor het in kaart brengen van gebeurtenisbronnen met SQS). Vang mislukkingen van de Lambda-aanroep op, niet de wachtrij.
# Terraform: Lambda con SQS event source e DLQ configurata sulla coda

resource "aws_lambda_function" "ordini_consumer" {
  function_name = "ordini-consumer"
  handler       = "handler.lambda_handler"
  runtime       = "python3.12"
  role          = aws_iam_role.lambda_role.arn
  timeout       = 30  # 30 secondi per messaggio

  # DLQ a livello di Lambda (solo per invocazioni async dirette)
  dead_letter_config {
    target_arn = aws_sqs_queue.lambda_dlq.arn
  }
}

# SQS come event source per Lambda
resource "aws_lambda_event_source_mapping" "sqs_trigger" {
  event_source_arn = aws_sqs_queue.ordini.arn
  function_name    = aws_lambda_function.ordini_consumer.arn
  batch_size       = 10
  enabled          = true

  # Bisection: in caso di errore batch, prova prima con metà messaggi
  # Aiuta a isolare il poison pill senza mandare tutti in DLQ
  bisect_batch_on_function_error = true

  # Report batch item failures: Lambda può indicare quali specifici
  # messaggi nel batch hanno fallito (solo quelli vanno in DLQ)
  function_response_types = ["ReportBatchItemFailures"]
}

ReportBatchItemFailures: gedetailleerde DLQ voor batch

// Handler Lambda Python con batch item failures
// Solo i messaggi falliti vanno in DLQ, non l'intero batch

def lambda_handler(event, context):
    """
    ReportBatchItemFailures: ritorna solo i message ID falliti.
    SQS mandrà in DLQ solo quelli, non il batch intero.
    """
    failed_items = []

    for record in event['Records']:
        message_id = record['messageId']
        try:
            # Elabora il messaggio
            payload = json.loads(record['body'])
            process_ordine(payload)
            print(f"Successo: {message_id}")

        except PermanentError as e:
            # Errore permanente: vai in DLQ subito
            print(f"PERMANENTE: {message_id} - {e}")
            failed_items.append({'itemIdentifier': message_id})

        except TransientError as e:
            # Errore transitorio: riprova (non aggiungere a failed)
            # SQS ritenterà l'intero batch se almeno uno fallisce
            # Con ReportBatchItemFailures, solo i falliti vengono ritentati
            print(f"TRANSITORIO: {message_id} - {e}")
            failed_items.append({'itemIdentifier': message_id})

    return {'batchItemFailures': failed_items}

DLQ in EventBridge

EventBridge heeft zijn eigen DLQ-niveau doel: indien de levering van een evenement naar het doel (Lambda, SQS) mislukt nadat alle geconfigureerde nieuwe pogingen zijn gedaan, wordt de gebeurtenis verzonden in de SQS DLQ gespecificeerd in dead_letter_config van de regel.

# EventBridge DLQ per target Lambda
resource "aws_cloudwatch_event_target" "ordini_lambda" {
  rule           = aws_cloudwatch_event_rule.ordini.name
  event_bus_name = aws_cloudwatch_event_bus.mioapp.name
  arn            = aws_lambda_function.ordini_consumer.arn

  # Retry policy di EventBridge: quanti tentativi prima di DLQ
  retry_policy {
    maximum_event_age_in_seconds = 86400  # Riprova per max 24h
    maximum_retry_attempts       = 185    # ~exponential backoff su 24h
  }

  # DLQ per eventi non consegnati
  dead_letter_config {
    arn = aws_sqs_queue.eventbridge_dlq.arn
  }
}

# L'evento in DLQ EventBridge include metadata di debug
# {
#   "version": "1.0",
#   "timestamp": "...",
#   "requestId": "...",
#   "condition": "...",
#   "approximateInvokeCount": 185,
#   "requestParameters": {
#     "FunctionName": "ordini-consumer"
#   },
#   "responseParameters": {
#     "statusCode": 500,
#     "errorCode": "Lambda.ServiceException"
#   },
#   "originalEvent": { ... l'evento originale ... }
# }

Geavanceerd patroon: Probeer het opnieuw met progressieve uitstel met behulp van SQS-vertraging

Met SQS kunt u een vertraging voor één bericht (tot 15 minuten). Gecombineerd met de FIFO-wachtrij en de berichten groep, het is mogelijk om te implementeren een exponentieel patroon voor opnieuw proberen zonder andere berichten te blokkeren:

// RetryWithSqsDelay.java - Retry progressivo con SQS message delay
public class SqsExponentialRetry {

    private static final int MAX_ATTEMPTS = 5;
    private static final int MAX_DELAY_SECONDS = 900;  // 15 minuti (max SQS)

    public void handleWithRetry(String queueUrl, Message sqsMessage) {
        // Leggi il numero di tentativi corrente dal message attribute
        int currentAttempt = Integer.parseInt(
            sqsMessage.messageAttributes()
                .getOrDefault("retryAttempt",
                    MessageAttributeValue.builder().stringValue("0").build())
                .stringValue()
        );

        try {
            processMessage(sqsMessage.body());
            // Successo: elimina dalla coda
            sqsClient.deleteMessage(...);

        } catch (TransientException e) {
            if (currentAttempt >= MAX_ATTEMPTS) {
                // Troppi tentativi: manda in DLQ manuale
                sendToManualDLQ(sqsMessage, e);
                sqsClient.deleteMessage(...);
                return;
            }

            // Calcola delay esponenziale (1s, 2s, 4s, 8s, 16s...)
            int delaySeconds = (int) Math.min(
                Math.pow(2, currentAttempt),
                MAX_DELAY_SECONDS
            );

            // Rimanda il messaggio con delay e contatore incrementato
            sqsClient.sendMessage(
                SendMessageRequest.builder()
                    .queueUrl(queueUrl)
                    .messageBody(sqsMessage.body())
                    .delaySeconds(delaySeconds)
                    .messageAttributes(Map.of(
                        "retryAttempt", MessageAttributeValue.builder()
                            .stringValue(String.valueOf(currentAttempt + 1))
                            .dataType("Number")
                            .build()
                    ))
                    .build()
            );

            // Elimina il messaggio originale (non usare la DLQ automatica)
            sqsClient.deleteMessage(...);
        }
    }
}

DLQ-monitoring: essentiële waarschuwingen

DLQ moet actief worden gemonitord. Een bericht in DLQ duidt op een reëel probleem dat vraagt om aandacht. De SQS-statistieken die u op CloudWatch moet controleren:

# CloudWatch Metric Alarms per DLQ - AWS CLI

# Alert: qualsiasi messaggio in DLQ (soglia 0)
aws cloudwatch put-metric-alarm \
  --alarm-name "ordini-dlq-not-empty" \
  --alarm-description "CRITICO: messaggi in DLQ ordini" \
  --metric-name "ApproximateNumberOfMessagesVisible" \
  --namespace "AWS/SQS" \
  --dimensions Name=QueueName,Value=ordini-queue-dlq \
  --period 60 \
  --evaluation-periods 1 \
  --statistic Sum \
  --comparison-operator GreaterThanThreshold \
  --threshold 0 \
  --alarm-actions "arn:aws:sns:eu-west-1:123456:alerts-topic"

# Metriche importanti da monitorare su DLQ:
# - ApproximateNumberOfMessagesVisible: messaggi pronti per essere letti
# - ApproximateNumberOfMessagesNotVisible: messaggi in processing
# - NumberOfMessagesSent: rate di arrivo in DLQ (crescita = problema)
# - NumberOfMessagesDeleted: rate di reprocessing

Best practices voor DLQ in asynchrone systemen

  • DLQ verplicht voor iedere consument: Er is geen asynchroon systeem in de productie zonder DLQ. Als ze ontbreken, gaan mislukte berichten verloren of blokkeren ze de stroom.
  • Bewaak DLQ met nuldrempelwaarschuwingen: elk bericht in DLQ is een teken van een probleem. Wacht niet tot er honderden zijn verzameld voordat u reageert.
  • Verrijk DLQ-berichten met metadata: kopteksten of attributen toevoegen met het fouttype, stacktrace, aantal nieuwe pogingen en tijdstempel van de fout. Zonder deze gegevens is het debuggen vrijwel onmogelijk.
  • Lange retentie op de DLQ: configureer een retentie van 14 dagen (maximale SQS) of minstens 30 dagen op Kafka. Berichten in DLQ moeten beschikbaar zijn voor uitgestelde analyse.
  • Test de herverwerking regelmatig: DLQ is nutteloos als je het niet weet hoe u berichten opnieuw kunt verwerken. Documenteer en test het herverwerkingsproces.
  • Aparte fouttypen: Overweeg afzonderlijke DLQ's voor permanente fouten (corrupte payloads) en tijdelijke fouten (services uitgevallen). De herverwerkingsstrategie is anders.

Antipatroon: negeer de DLQ

Het gevaarlijkste patroon is om de DLQ te configureren en deze vervolgens niet te monitoren. De berichten stapelen zich wekenlang in stilte op, maar dan merkt iemand het op kritische gegevens ontbreken. Zet altijd een alert op de DLQ: het is het vangnet die je nooit mag negeren.

Volgende stappen in de serie

  • Artikel 9 – Idempotentie bij consumenten: nieuwe pogingen en herverwerking vanuit DLQ kan dubbele berichten veroorzaken. Het idempotentie-sleutelpatroon is de belangrijkste verdediging.
  • Artikel 10 – Outbox-patroon: zorg ervoor dat een evenement wordt gepubliceerd gebeurt altijd, zelfs in het geval van een producercrash, met behulp van een outbox-tabel in de database.

Link met andere series

  • SQS versus SNS versus EventBridge (artikel 7): Elke AWS-service heeft zijn eigen service semantiek van DLQ en opnieuw proberen. In dit artikel worden de configuratieverschillen tussen de services besproken.
  • Kafka Dead Letter Queue (artikel 10 van de Kafka-serie): het DLQ-patroon in Kafka met consumentengroep, onderwerp opnieuw proberen en opnieuw verwerken heeft dezelfde basis conceptueel maar een andere implementatie dan SQS.