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.