
Chi non ha mai sentito il termine “resilienza”? In questo periodo di pandemia, forse, si è sentito ancora di più, tanto che è stato associato ai piani dei vari paesi per ripartire dopo questo periodo buio.
Ma cosa intendiamo per resilienza? La scienza indica con questa parola la capacità di un materiale di resistere alla rottura in seguito ad una sollecitazione dinamica e per alcuni materiali la capacità di riprendere, dopo una deformazione, l’aspetto originale.
Non vi sorprenderà, allora, che questo termine sia utilizzato anche in ambito software. L’INCOSE (International Council on Systems Engineering), che si occupa di promuovere l’ingegneria dei sistemi attraverso conferenze, gruppi di lavoro e pubblicazioni, fornisce questa definizione:
La Resilienza è l’abilità di fornire le risposte necessarie nell’affrontare le avversità (evitando, resistendo, recuperando, evolvendo e adattandosi)
Nelle applicazioni cloud a microservizi o, in generale, in quelle distribuite, in cui è presente una continua comunicazione tra servizi o risorse esterne, sono molto frequenti errori temporanei o transienti. Si tratta di errori dovuti a perdita di connessione o indisponibilità temporanea di un servizio. La natura di questo tipo di errori implica che un successivo tentativo di richiesta o operazione possa andare a buon fine.
Uno dei modi per rendere resilienti e robusti e quindi stabili questi sistemi è ricorrere al RETRY Pattern. Questo pattern consiste semplicemente nel ripetere un’operazione a seguito di un fallimento.
Le strategie da applicare secondo questo pattern sono diverse e dipendono dalla tipologia di errore riscontrato:
- Cancel – Errore non transiente, sarebbe inutile riprovare l’operazione fallita. Bisogna fermare l’operazione e notificare l’errore mediante eccezione o magari gestirlo.
- Retry – Errore raro o inusuale. È molto probabile che ripetendo l’operazione essa abbia esito positivo
- Retry after delay – Errore noto dovuto a problemi di connessione o occupazione di un servizio. Potrebbe essere necessario ritardare la ripetizione dell’operazione prima di riprovare.
Lavorando su un sistema di un nostro cliente, in cui più servizi comunicano tra loro tramite messaggi scambiati mediante RabbitMQ, è capitato più volte di dover fare interventi sul meccanismo di retry strategy presente sui loro software. Questo meccanismo era stato implementato internamente al team e non era facilmente mantenibile. Così insieme al team si è deciso di fare un bel refactoring al codice e di introdurre una libreria già esistente, nata proprio con lo scopo di aiutare gli sviluppatori nella gestione degli errori e rendere le applicazioni resilienti attraverso diverse policy di gestione. Questa libreria si chiama Polly ed è riconosciuta ufficialmente da Microsoft.

Polly offre diverse policy di resilienza che potete trovare qui ma oggi ci soffermeremo su quella di Retry.

Per mostrare meglio il funzionamento di Polly può essere utile un esempio. Prendiamo il codice da uno dei primi articoli su RabbitMQ che abbiamo pubblicato sul nostro blog.
In particolare, prendiamo il codice del Sender ovvero un software che invia messaggi tramite un Broker di messaggi RabbitMQ.
Per simulare un errore transiente andremo a stoppare il container su cui viene eseguito il broker RabbitMQ e lo riavvieremo subito dopo.
Partiamo dal codice di base a cui ho apportato alcune modifiche. A scopo illustrativo ho creato un array di stringhe, ovvero i messaggi che invieremo, riempito tramite il metodo FillMessages(), inoltre ho estratto in un metodo la parte di codice che va a sfruttare il driver di RabbitMQ per la pubblicazione dei messaggi. Per evitare la perdita di messaggi in caso di stop temporaneo del broker, andremo a settare la coda dichiarata come durable e andremo a specificare che i messaggi pubblicati saranno di tipo persistent.
namespace Sender
{
class Sender
{
public static void Main()
{
var factory = new ConnectionFactory() { HostName = "localhost" };
using (var connection = factory.CreateConnection())
using (var channel = connection.CreateModel())
{
channel.QueueDeclare(queue: "QueueDemo",
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
var messages = new string [100];
FillMessages(messages);
foreach (var message in messages)
{
PublishMessage(message, channel);
Thread.Sleep(2000);
}
}
Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();
}
private static void FillMessages(string[] arrayToFill)
{
for (var j = 0; j < arrayToFill.Length; j++)
{
arrayToFill[j] = $"Message {j}";
}
}
private static void PublishMessage(string message, IModel channel)
{
var body = Encoding.UTF8.GetBytes(message);
channel.BasicPublish(exchange: "",
routingKey: "QueueDemo",
basicProperties: new BasicProperties{DeliveryMode = 2},
body: body);
Console.WriteLine($"Sent {message}");
}
}
}
Per utilizzare Polly in una nostra applicazione ci sono degli step da seguire.
Step 1) Specificare l’eccezione/i che la policy deve gestire
Se lanciassimo il Sender e, durante l’invio dei messaggi, stoppassimo il container sui cui è eseguito RabbitMQ riceveremmo questo tipo di eccezione:
Unhandled Exception: RabbitMQ.Client.Exceptions.AlreadyClosedException: Already closed: The AMQP operation was interrupted
Definiamo allora una policy per gestire questa eccezione mediante il metodo Handle().
var retryPolicy = Policy.Handle< AlreadyClosedException >();
Step 2) Specificare come la policy deve gestire l’errore
Supponiamo, quindi, che la perdita di connessione al broker sia un problema noto o comunque un errore transiente che per quanto detto in procedenza è gestibile con una strategia di Retry. Ci sono diversi modi per definire con Polly il modo con cui si vuole riprovare una determinata operazione. Il metodo più semplice è il metodo Retry() al quale eventualmente possiamo passare un parametro di tipo intero per indicare il numero di volte che si vuole ripetere l’operazione. Oppure possiamo riprovare per sempre mediante il metodo RetryForever().
Come abbiamo detto in precedenza, quando siamo al cospetto di errori comuni di connessione, un’ottima strategia può essere attendere un certo periodo di tempo prima di riprovare. Inoltre, quando ci sono fallimenti continui, si può pensare di aumentare il ritardo tra i tentativi in maniera incrementale o esponenziale al fine di permettere al servizio o alla risorsa di tornare disponibile. Con Polly possiamo implementare questo tipo di Retry mediante il metodo WaitAndRetry() che prevede, tra i parametri, il numero di tentativi che si vuole effettuare e un intervallo di tempo che bisogna attendere tra un tentativo e l’altro. In aggiunta, è possibile specificare della logica da eseguire prima di ogni tentativo.
Il delay tra un tentativo e l’altro può essere definito in maniera esponenziale (exponential backoff). Tale definizione consente di avere intervalli tra i tentativi che crescono all’aumentare dei fallimenti consentendo al servizio di avere più tempo per ritornare a funzionare correttamente. Utilizzando il metodo WaitAndRetry() fornito da Polly, possiamo indicare una base che ad ogni retry verrà elevata ad una potenza pari al numero di tentativi effettuati.
var retryPolicy = Policy.Handle<AlreadyClosedException>()
.WaitAndRetry(5,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
(exception, timeSpan, retryCount, context) =>
{
Console.WriteLine($"This is the {retryCount} try");
}
);
Visto che l’exponential backoff è una progressione fissa, in scenari dove il throughput è veramente alto, è possibile inoltre introdurre un jitter (quantità di tempo aleatoria) per evitare picchi di carico ed introdurre così una certa aleatorietà nel calcolo del delay.
Step 3) Eseguire il codice attraverso la policy
Stabilita la policy, è il momento di eseguirla. Il metodo Execute() conterrà la parte di codice che ci interessa, o meglio, quella che può restituire l’eccezione che vogliamo gestire. Nel nostro caso, si tratta del metodo PublishMessage().
retryPolicy.Execute(() =>
PublishMessage(message, channel)
);
Andiamo ad effettuare la simulazione:

Se tramite la UI Management di RabbitMQ effettuiamo la GetMessages() possiamo vedere che anche il messaggio inviato dopo la retry è arrivato correttamente.

Come già detto questa strategia è molto utile nell’affrontare errori transienti o in generale quando gli errori che si vogliono gestire sono temporanei. Non sarebbe efficace, invece, nella gestione di errori di lunga durata. Inoltre, questo approccio non deve essere visto alternativo alla scalabilità, in quanto non risolve i problemi di carico. Se ho un sistema a singolo broker e un numero di richieste molto elevato, la strategia di retry può aiutare nell’inoltrare al broker eventuali messaggi falliti, ma ciò non significa che posso affidarmi ad un sistema con meno risorse e ciò vale anche se si pensa alle performance.
Oltre ad approcci reattivi come la strategia di Retry o il Circuit Braker, Polly mette sul piatto anche strategie proattive orientate alla stabilità ovvero tecniche di resilienza di tipo preventivo. In aggiunta, è possibile utilizzare Polly insieme all’HttpClientFactory di ASPNET Core (dalla versione 2.1) per applicare le policies alle chiamate HTTP.
In chiusura lascio il link al repository dove potete trovare il codice dell’esempio.
Al prossimo articolo!