Salta al contenuto principale

08 - Dead Letter Queue e Resilienza nei Sistemi Asincroni

La dead letter queue serve se distingui gli errori da ritentare da quelli da isolare: backoff con jitter, lotti con un messaggio velenoso e le trappole di SQS.

Cosa ne pensi di questo articolo?

08 - Dead Letter Queue e Resilienza nei Sistemi Asincroni
Event-Driven ArchitectureFoto:cottonbro studioPexels

In Breve

La dead letter queue serve se distingui gli errori da ritentare da quelli da isolare: backoff con jitter, lotti con un messaggio velenoso e le trappole di SQS.

Un messaggio che non passa, e quelli buoni dietro di lui

In un sistema asincrono qualche messaggio fallirà. Il caso semplice è l'errore temporaneo: il gateway di pagamento va in timeout, e riprovare tra qualche secondo basta. Il caso insidioso è il poison pill: un messaggio malformato o che viola una regola di business, per cui riprovare mille volte darà mille fallimenti, consumando risorse e, se la coda è ordinata, bloccando tutto ciò che sta dietro.

La dead letter queue (DLQ) è la risposta: dopo un numero di tentativi o al primo errore non recuperabile, il messaggio viene tolto dal flusso e messo in una coda a parte, dove un essere umano o un processo può guardarlo.

Una DLQ non è una soluzione, è un rinvio: sposta il problema da «il sistema è fermo» a «ci sono messaggi da guardare», e funziona solo se qualcuno li guarda. In questo articolo distinguo gli errori che si ritentano da quelli che non si ritentano, eseguo ritentativi con backoff e DLQ in un modello, misuro quanto costa processare i lotti nel modo sbagliato e riporto, dalla documentazione ufficiale consultata il 2026-11-12, i comportamenti di SQS, Lambda ed EventBridge che sorprendono.

Il contesto è nell'articolo su SQS, SNS ed EventBridge; il motivo per cui i messaggi arrivano due volte è la consegna almeno una volta, trattata nel pezzo sull'idempotenza. In Kafka lo stesso problema, con i retry topic e il commit dell'offset, è nell'articolo sulla dead letter queue in Kafka.

Ritentare, aspettare, arrendersi: il modello

Il programma classifica ogni errore. Un ErroreTransitorio (timeout, servizio non disponibile) merita un altro tentativo dopo un'attesa; qualunque altro errore (qui il SyntaxError di un JSON incompleto) è permanente e manda il messaggio subito in DLQ, senza sprecare tentativi. L'attesa cresce in modo esponenziale: tra 0 e base × 2tentativo secondi, con un tetto, estratta a caso (full jitter).

Il caso serve a evitare che cento consumatori falliti nello stesso istante riprovino tutti insieme: l'Architecture Blog di AWS descrive l'aggiunta del jitter come la correzione che disperde i tentativi. Per rendere l'uscita ripetibile il generatore ha un seme fisso. Gli effetti sono protetti da una chiave di idempotenza: un messaggio riprocessato non applica due volte il suo effetto.

class ErroreTransitorio extends Error {}

// Numeri pseudo-casuali ripetibili: stesso seme, stesso jitter, stessa uscita.
let seme = 42;
const casuale = () => (seme = (seme * 1664525 + 1013904223) % 4294967296) / 4294967296;

// Backoff esponenziale con "full jitter": attesa a caso fra 0 e min(tetto, base * 2^tentativo).
function attesa(tentativo: number, base = 1, tetto = 60): number {
  return Math.round(casuale() * Math.min(tetto, base * 2 ** tentativo) * 10) / 10;
}

type Messaggio = { id: string; body: string };
type VoceDlq = { id: string; body: string; motivo: string; tentativi: number };
const dlq: VoceDlq[] = [];
const effetti = new Map<string, number>();            // quante volte l'effetto e' stato applicato, per messaggio
const giaFatti = new Set<string>();                   // chiavi di idempotenza

let gatewayGiu = 2;        // il gateway fallisce 2 volte e poi riparte
let servizioGiu = true;    // questo servizio resta giu' finche' qualcuno non lo ripristina

function handler(m: Messaggio): void {
  const dato = JSON.parse(m.body);                    // JSON non valido: SyntaxError, inutile ritentare
  if (dato.via === 'gateway' && gatewayGiu-- > 0) throw new ErroreTransitorio('timeout gateway');
  if (dato.via === 'servizio' && servizioGiu) throw new ErroreTransitorio('servizio non disponibile');
  if (giaFatti.has(m.id)) return;                     // idempotenza: l'effetto si applica una volta
  giaFatti.add(m.id);
  effetti.set(m.id, (effetti.get(m.id) ?? 0) + 1);
}

function elabora(m: Messaggio, maxTentativi = 4): void {
  for (let t = 1; t <= maxTentativi; t++) {
    try { handler(m); console.log(`${m.id}: ok al tentativo ${t}`); return; }
    catch (e) {
      if (!(e instanceof ErroreTransitorio)) {                 // permanente: subito in DLQ
        dlq.push({ id: m.id, body: m.body, motivo: `permanente: ${(e as Error).name}`, tentativi: t });
        console.log(`${m.id}: errore permanente al tentativo ${t}, in DLQ`); return;
      }
      if (t === maxTentativi) {
        dlq.push({ id: m.id, body: m.body, motivo: `transitorio esaurito: ${(e as Error).message}`, tentativi: t });
        console.log(`${m.id}: tentativi esauriti (${t}), in DLQ`); return;
      }
      console.log(`${m.id}: tentativo ${t} fallito (${(e as Error).message}), attendo ${attesa(t)} s`);
    }
  }
}

const coda: Messaggio[] = [
  { id: 'm1', body: '{"via":"locale"}' },
  { id: 'm2', body: '{"via":"gateway"}' },
  { id: 'm3', body: '{"via":"locale"' },            // manca la graffa finale
  { id: 'm4', body: '{"via":"servizio"}' },
];
coda.forEach((m) => elabora(m));
console.log('DLQ:', dlq.map((v) => `${v.id} [${v.motivo}, tentativi: ${v.tentativi}]`).join(' | '));
console.log('allarme DLQ non vuota:', dlq.length > 0);

// Rimedio: si ripara il dato di m3, si ripristina il servizio e si rimandano le voci nella coda principale.
servizioGiu = false;
const daRiprocessare = dlq.splice(0).map((v) => ({ id: v.id, body: v.id === 'm3' ? '{"via":"locale"}' : v.body }));
console.log('--- redrive');
daRiprocessare.forEach((m) => elabora(m));
console.log('DLQ dopo il redrive:', dlq.length, '| effetti per messaggio:', Object.fromEntries(effetti));

// Output:
// m1: ok al tentativo 1
// m2: tentativo 1 fallito (timeout gateway), attendo 0.5 s
// m2: tentativo 2 fallito (timeout gateway), attendo 0.4 s
// m2: ok al tentativo 3
// m3: errore permanente al tentativo 1, in DLQ
// m4: tentativo 1 fallito (servizio non disponibile), attendo 1.2 s
// m4: tentativo 2 fallito (servizio non disponibile), attendo 0.9 s
// m4: tentativo 3 fallito (servizio non disponibile), attendo 3 s
// m4: tentativi esauriti (4), in DLQ
// DLQ: m3 [permanente: SyntaxError, tentativi: 1] | m4 [transitorio esaurito: servizio non disponibile, tentativi: 4]
// allarme DLQ non vuota: true
// --- redrive
// m3: ok al tentativo 1
// m4: ok al tentativo 1
// DLQ dopo il redrive: 0 | effetti per messaggio: { m1: 1, m2: 1, m3: 1, m4: 1 }

m2 mostra l'errore transitorio che si risolve: due timeout e il terzo tentativo va. m3 è il poison pill: un solo tentativo, poi DLQ con motivo e numero di tentativi, per non perdere informazione. m4 dimostra il limite del ritentativo: il servizio è giù per tutto l'intervallo, i quattro tentativi si esauriscono e il messaggio finisce in DLQ come «transitorio esaurito»; è lo stesso messaggio di un servizio che si riprenderà, e la DLQ lo conserva finché non succede.

Nella fase di redrive si ripara il dato di m3, si ripristina il servizio e si rimandano le voci nella coda principale: entrambe riescono, la DLQ torna vuota e ogni effetto vale 1, non 2, grazie alla chiave di idempotenza. I tempi di attesa sono scritti nel log ma non attesi: è il tempo virtuale di un modello, non una misura.

Il lotto: quando un messaggio cattivo ne porta via quattro buoni

Se il consumatore riceve i messaggi a lotti (come fa una Lambda collegata a SQS), un fallimento può significare «il lotto intero» oppure «questo messaggio». La differenza si misura con un lotto di cinque messaggi, di cui uno velenoso, e una coda che sposta in DLQ dopo tre ricezioni. Nella prima variante la funzione lancia un'eccezione al primo errore, quindi nessun messaggio viene cancellato; nella seconda restituisce l'elenco dei soli messaggi falliti, e i riusciti vengono cancellati.

// Lotto di 5 messaggi, il terzo e' un poison pill. La coda ha maxReceive=3: alla terza ricezione fallita va in DLQ.
type Msg = { id: string; veleno: boolean };
const MAX_RICEZIONI = 3;

function simula(nome: string, rispondi: 'tutto-il-lotto' | 'solo-i-falliti'): void {
  const coda: (Msg & { ricezioni: number })[] = ['a', 'b', 'c', 'd', 'e'].map((id) => ({ id, veleno: id === 'c', ricezioni: 0 }));
  const dlq: string[] = [];
  let effettiEseguiti = 0;                       // quante volte un messaggio "buono" ha eseguito il suo effetto
  for (let giro = 1; giro <= MAX_RICEZIONI && coda.length > 0; giro++) {
    const lotto = [...coda];
    lotto.forEach((m) => m.ricezioni++);
    const falliti: string[] = [];
    for (const m of lotto) {
      if (m.veleno) { falliti.push(m.id); continue; }
      effettiEseguiti++;                         // senza idempotenza, l'effetto si ripete a ogni giro
    }
    if (falliti.length === 0) { coda.length = 0; break; }
    // Con "tutto-il-lotto" la funzione lancia un'eccezione: nessun messaggio viene cancellato.
    // Con "solo-i-falliti" restituisce l'elenco: i riusciti vengono cancellati, gli altri restano.
    const restano = rispondi === 'tutto-il-lotto' ? lotto : lotto.filter((m) => falliti.includes(m.id));
    coda.splice(0, coda.length, ...restano);
  }
  // Dopo MAX_RICEZIONI ricezioni, quello che resta va in DLQ.
  for (const m of coda) if (m.ricezioni >= MAX_RICEZIONI) dlq.push(m.id);
  console.log(`${nome.padEnd(16)} effetti dei messaggi buoni: ${effettiEseguiti} (attesi 4) | in DLQ: [${dlq}]`);
}

simula('tutto il lotto', 'tutto-il-lotto');
simula('solo i falliti', 'solo-i-falliti');

// Output:
// tutto il lotto   effetti dei messaggi buoni: 12 (attesi 4) | in DLQ: [a,b,c,d,e]
// solo i falliti   effetti dei messaggi buoni: 4 (attesi 4) | in DLQ: [c]

Nella variante «tutto il lotto» i quattro messaggi buoni sono rielaborati a ogni giro: dodici esecuzioni dell'effetto invece di quattro, e alla fine tutti e cinque sono nella DLQ, buoni compresi. Il poison pill ha trascinato con sé i messaggi sani, e senza idempotenza avrebbe anche duplicato dodici volte ciò che dovevano fare una.

Nella variante «solo i falliti» l'effetto si esegue quattro volte e nella DLQ finisce solo c. La documentazione di Lambda lo descrive così: con ReportBatchItemFailures nella configurazione della mappatura e una risposta con batchItemFailures, solo i messaggi indicati tornano visibili; se la funzione lancia un'eccezione, l'intero lotto è considerato fallito. Il modello è semplificato: nel servizio vero il passaggio alla DLQ avviene secondo le regole di maxReceiveCount descritte sotto.

Cosa dice la documentazione dei servizi veri

Questi comportamenti non si vedono nel modello e cambiano il progetto. Vengono tutti dalle guide ufficiali di AWS consultate il 2026-11-12.

  • In SQS, maxReceiveCount è il numero di volte in cui un consumatore può ricevere un messaggio dalla coda sorgente prima che passi alla DLQ: con valore 1, un solo fallimento lo sposta.
  • Per le code standard la scadenza di un messaggio si basa sull'istante originale di accodamento, che non cambia quando passa alla DLQ: con una conservazione della DLQ di 4 giorni, il messaggio scompare dopo 3 se ne ha già passato 1 nella coda sorgente. La guida consiglia una conservazione della DLQ più lunga di quella della coda sorgente.
  • Per le code FIFO l'istante di accodamento viene azzerato quando il messaggio passa alla DLQ, e la guida avverte di non usare una DLQ con una FIFO se non vuoi rompere l'ordine esatto dei messaggi.
  • I timer sui singoli messaggi (un ritardo di consegna fino a 15 minuti) non sono supportati dalle code FIFO: un backoff basato sul ritardo del singolo messaggio funziona solo sulle code standard.
  • EventBridge ritenta per default per 24 ore e fino a 185 volte; se la consegna a un target fallisce definitivamente l'evento viene scartato, a meno che sul target sia configurata una DLQ.

Il secondo punto è quello che fa più danni, perché è silenzioso: una DLQ con la conservazione più corta della coda sorgente perde i messaggi proprio quando il team ne ha più bisogno. Il quarto smonta un pattern diffuso, il ritentativo con ritardo del singolo messaggio su una coda FIFO: non si può fare; su FIFO il ritardo è a livello di coda. Il quinto è la ragione per cui ogni target di EventBridge ha una DLQ: senza, dopo un giorno di errori l'evento non esiste più. Per i dettagli di EventBridge si veda l'articolo dedicato.

Guardare la DLQ, e riprocessare con metodo

Il segnale di allarme più utile è il più semplice: la DLQ non è vuota. Le metriche di SQS includono ApproximateNumberOfMessagesVisible (i messaggi disponibili per essere letti) e ApproximateAgeOfOldestMessage, quest'ultima citata dalla guida proprio a proposito delle DLQ. Un allarme con soglia zero sulla prima ti avvisa subito; quello sull'età ti dice se qualcuno sta lasciando marcire i messaggi.

Il redrive (rimandare i messaggi dalla DLQ verso la coda sorgente) è un'operazione che la guida prevede per SQS, ma l'ordine con cui l'hai eseguito nel modello vale sempre: prima si capisce perché il messaggio è fallito, poi si corregge dato o codice, poi si rimanda, e il consumatore deve essere idempotente perché alcuni di quei messaggi potrebbero aver già prodotto un effetto parziale. Arricchire la voce della DLQ con motivo e numero di tentativi, come fa il programma, è ciò che rende possibile il primo passo.

Errori frequenti

Il primo è configurare la DLQ e non guardarla: i messaggi si accumulano per settimane e poi qualcuno scopre che mancano dati. Il secondo è un maxReceiveCount troppo basso, che manda in DLQ messaggi sani a ogni breve indisponibilità di un servizio; il terzo è troppo alto, che ritarda la scoperta di un guasto vero.

Il quarto è mescolare in un'unica DLQ errori permanenti (dato corrotto) e transitori (servizio giù): hanno rimedi diversi, e tenerli distinti, o almeno etichettati come nel modello, evita di rimandare in coda un dato che fallirà di nuovo. Il quinto è l'ordine: se il flusso deve restare in ordine, una DLQ lo sposta, e va deciso prima se quel messaggio può essere saltato.

Che cosa sai fare adesso

Sai separare gli errori che si ritentano da quelli che vanno subito in DLQ, sai spiegare perché il backoff ha il jitter e come si riprocessa con idempotenza. Sai misurare il costo di una risposta sbagliata sul lotto (dodici esecuzioni e cinque messaggi in DLQ contro quattro e uno) e conosci i quattro punti della documentazione che non si vedono finché non fanno male: la scadenza che parte dall'accodamento originale, l'ordine sulle code FIFO, i timer che FIFO non ha e il default di 24 ore di EventBridge. Il passo successivo è il fondamento che regge tutto questo: l'idempotenza dei consumer. I ritentativi e le compensazioni che falliscono dentro una saga sono nell'articolo sulla saga; ciò che l'utente vede mentre i messaggi aspettano in coda è nella consistenza eventuale.

Fonti

I due programmi (retry.ts e batch.ts) sono stati eseguiti con Node.js v24.11.0; le uscite sono quelle reali del modello e i tempi di attesa sono virtuali. Nessun servizio AWS è stato chiamato: i comportamenti di SQS, Lambda ed EventBridge sono riportati dalle guide.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti