RO EN

Azure Service Bus (3) — dead-letter queue și retry policies

Azure Service Bus (3) — dead-letter queue și retry policies ✨ Imagine generată cu AI
Doru Bulubașa
23 iulie 2026
38 vizualizări

A treia parte din seria despre Azure Service Bus. Avem producători și consumatori funcționali. Acum răspundem la întrebarea inevitabilă: ce se întâmplă când procesarea eșuează?


Ciclul de viață al unui mesaj eșuat

Recapitulare din partea 2: cu AutoCompleteMessages = false, un mesaj e eliminat din coadă doar când apelezi CompleteMessageAsync. Dacă handler-ul aruncă excepție, mesajul e abandonat și revine în coadă pentru relivrare.

Fiecare relivrare incrementează DeliveryCount. Când depășește MaxDeliveryCount (default: 10), Service Bus mută automat mesajul în dead-letter queue (DLQ) — o sub-coadă atașată fiecărei cozi și fiecărui subscription.

Mesaj --> procesare esueaza --> abandon --> revine in coada (DeliveryCount+1)
      --> ... de MaxDeliveryCount ori ...
      --> mutat automat in DLQ (orders/$DeadLetterQueue)

DLQ-ul e plasa de siguranță: niciun mesaj nu se pierde, dar niciun mesaj otrăvit (poison message) nu blochează la infinit procesarea celorlalte.

# Configurare MaxDeliveryCount la creare
az servicebus queue create \
  --name order-processing \
  --namespace-name my-servicebus \
  --resource-group my-rg \
  --max-delivery-count 5

Erori tranzitorii vs. permanente

Cheia unui retry inteligent e clasificarea erorii. Nu toate eșecurile merită retry:

  • Tranzitorii — timeout la baza de date, serviciu downstream temporar indisponibil, throttling (429). Retry-ul are sens: peste 30 de secunde probabil funcționează.
  • Permanente — mesaj malformat, validare de business eșuată, entitate inexistentă. Retry-ul e inutil: același mesaj va eșua identic de fiecare dată. Trimite-l direct în DLQ.
private async Task OnMessageAsync(ProcessMessageEventArgs args)
{
    using var scope = _scopeFactory.CreateScope();
    var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();

    try
    {
        var evt = JsonConvert.DeserializeObject<OrderPlacedEvent>(
            args.Message.Body.ToString());

        if (evt is null)
        {
            // Eroare PERMANENTA: mesaj malformat -- direct in DLQ, fara retry
            await args.DeadLetterMessageAsync(args.Message,
                deadLetterReason: "DeserializationFailed",
                deadLetterErrorDescription: "Body-ul nu e un OrderPlacedEvent valid");
            return;
        }

        await handler.HandleAsync(evt, args.CancellationToken);
        await args.CompleteMessageAsync(args.Message);
    }
    catch (BusinessValidationException ex)
    {
        // Eroare PERMANENTA: regula de business incalcata -- DLQ cu context
        await args.DeadLetterMessageAsync(args.Message,
            deadLetterReason: "BusinessValidationFailed",
            deadLetterErrorDescription: ex.Message);
    }
    catch (Exception)
    {
        // Eroare TRANZITORIE (sau necunoscuta): abandon -- revine pentru retry
        // Dupa MaxDeliveryCount incercari, ajunge automat in DLQ
        await args.AbandonMessageAsync(args.Message);
        throw;
    }
}

DeadLetterMessageAsync cu deadLetterReason explicit e esențial: când analizezi DLQ-ul peste o săptămână, motivul te scutește de arheologie prin loguri.


Retry cu backoff exponențial

Abandonul simplu relivrează mesajul aproape imediat — util pentru erori de moment, dar contraproductiv când downstream-ul are nevoie de timp să-și revină (retry-uri rapide consecutive pot agrava problema).

Service Bus nu are backoff nativ per mesaj, dar îl construiești elegant cu mesaje programate: în loc să abandonezi, copiezi mesajul cu ScheduledEnqueueTime întârziat progresiv:

private async Task RetryWithBackoffAsync(ProcessMessageEventArgs args)
{
    var retryCount = args.Message.ApplicationProperties.TryGetValue("RetryCount", out var rc)
        ? (int)rc : 0;

    const int maxRetries = 5;

    if (retryCount >= maxRetries)
    {
        await args.DeadLetterMessageAsync(args.Message,
            deadLetterReason: "MaxRetriesExceeded",
            deadLetterErrorDescription: $"Esuat dupa {maxRetries} incercari cu backoff");
        return;
    }

    // Backoff exponential: 10s, 20s, 40s, 80s, 160s
    var delay = TimeSpan.FromSeconds(10 * Math.Pow(2, retryCount));

    var retryMessage = new ServiceBusMessage(args.Message.Body)
    {
        ContentType = args.Message.ContentType,
        MessageId = $"{args.Message.MessageId}-retry-{retryCount + 1}",
        Subject = args.Message.Subject,
        CorrelationId = args.Message.CorrelationId,
        ScheduledEnqueueTime = DateTimeOffset.UtcNow.Add(delay)
    };

    foreach (var prop in args.Message.ApplicationProperties)
        retryMessage.ApplicationProperties[prop.Key] = prop.Value;
    retryMessage.ApplicationProperties["RetryCount"] = retryCount + 1;

    await _sender.SendMessageAsync(retryMessage);
    await args.CompleteMessageAsync(args.Message); // originalul e completat
}

Observație importantă: MessageId trebuie să difere per retry dacă ai duplicate detection activ — altfel Service Bus aruncă silențios copia programată.

Pentru retry-ul în interiorul unei procesări (apel HTTP către downstream), folosește Microsoft.Extensions.Http.Resilience — cele două straturi de retry se completează: resilience handler pentru micro-eșecuri de rețea, mesaje programate pentru macro-eșecuri de procesare.


Monitorizarea și reprocesarea DLQ

Citirea mesajelor moarte

// DLQ-ul e o sub-coada -- o accesezi prin SubQueue.DeadLetter
var dlqReceiver = client.CreateReceiver("order-processing",
    new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });

// Peek: citeste fara sa consume -- pentru inspectie
var messages = await dlqReceiver.PeekMessagesAsync(maxMessages: 50);

foreach (var msg in messages)
{
    Console.WriteLine($"Reason: {msg.DeadLetterReason}");
    Console.WriteLine($"Description: {msg.DeadLetterErrorDescription}");
    Console.WriteLine($"DeliveryCount: {msg.DeliveryCount}");
}

Serviciu de reprocesare

După ce ai reparat cauza (bug fix, downstream revenit), mesajele din DLQ se retrimit în coada principală:

public async Task<int> ReprocessDeadLettersAsync(
    string queueName, int maxMessages, CancellationToken ct)
{
    var dlqReceiver = _client.CreateReceiver(queueName,
        new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });
    var sender = _client.CreateSender(queueName);

    var reprocessed = 0;
    var messages = await dlqReceiver.ReceiveMessagesAsync(
        maxMessages, TimeSpan.FromSeconds(5), ct);

    foreach (var msg in messages)
    {
        var retryMessage = new ServiceBusMessage(msg.Body)
        {
            ContentType = msg.ContentType,
            MessageId = $"{msg.MessageId}-reprocessed",
            Subject = msg.Subject,
            CorrelationId = msg.CorrelationId
        };
        // Reset RetryCount -- pornim de la zero dupa fix
        foreach (var prop in msg.ApplicationProperties
                     .Where(p => p.Key != "RetryCount"))
            retryMessage.ApplicationProperties[prop.Key] = prop.Value;

        await sender.SendMessageAsync(retryMessage, ct);
        await dlqReceiver.CompleteMessageAsync(msg, ct);
        reprocessed++;
    }

    return reprocessed;
}

Alerte pe adâncimea DLQ

# Alerta cand DLQ-ul depaseste 10 mesaje
az monitor metrics alert create \
  --name "dlq-depth-alert" \
  --resource-group my-rg \
  --scopes $SERVICEBUS_ID \
  --condition "avg DeadletteredMessages > 10" \
  --window-size 5m \
  --evaluation-frequency 5m \
  --action $ACTION_GROUP_ID

Un DLQ care crește tăcut e o defecțiune invizibilă. Alerta pe DeadletteredMessages e obligatorie în producție — un mesaj în DLQ e un client al cărui email n-a plecat sau a cărui comandă n-a fost procesată.


Ce urmează

A rămas cea mai subtilă problemă: ce se întâmplă dacă salvezi comanda în baza de date, dar publicarea mesajului eșuează? Sau invers? În partea a patra: Outbox pattern — consistență între starea bazei de date și mesajele publicate.

Întrebări? Scrie-mi la contact@ludoprogramming.com.