TSM - Proiectarea sistemelor distribuite reziliente cu Polly și .NET 10

Daniela Crețu - Senior Software Engineer @ Cognizant

Într-un sistem distribuit, aplicațiile vor eșua, nu pentru că cineva a greșit sau aplicația a fost scrisă prost. Explicația eșecului este alta. Deoarece rețelele sunt imprevizibile, serviciile rulează pe mașini diferite sau se supraîncarcă, bazele de date pot deveni lente, iar APIurile externe uneori nu mai răspund. Atunci când multe servicii comunică între ele, chiar și o întârziere mică sau o întrerupere temporară poate provoca probleme mult mai mari dacă sistemul nu este pregătit.

De aceea, reziliența trebuie gândită încă de la început. Cu instrumente precum Polly, putem adăuga comportamente inteligente care ajută aplicațiile să se recupereze într-un mod robust, în loc să se blocheze sau să se prăbușească. Construirea rezilienței din timp asigură că sistemele noastre rămân stabile și receptive, oferind o experiență mai bună utilizatorilor.

Ce este Polly și de ce este atât de folosită

Polly este librăria de reziliență pe care întregul ecosistem .NET se bazează pentru a gestiona tipurile de erori care apar în mod natural în sistemele distribuite. Ea oferă modele integrate precum retry, timeout, circuit breaker și bulkhead, ajutând aplicațiile să rămână receptive chiar și atunci când serviciile externe devin lente sau nesigure.

Începând cu .NET 8 și continuând cu .NET 10+, Microsoft a integrat Polly direct în infrastructura de reziliență a platformei, în special prin HttpClientFactory, metoda recomandată pentru a efectua apeluri HTTP în aplicațiile moderne .NET. Această integrare le permite dezvoltatorilor să aplice politici de reziliență cu doar câteva linii de configurație, în loc să scrie logică personalizată de retry sau cod complex de tratare a erorilor.

Pentru că Polly este atât de puternică, dar și ușor de folosit, a devenit alegerea standard pentru construirea de microservicii fiabile în .NET, transformând-o într-un instrument esențial pentru inginerii de orice nivel care vor să proiecteze sisteme ce se comportă previzibil chiar și în condiții de stres.

Modele fundamentale de reziliență

A. Politica Retry

În sistemele distribuite, erorile temporare sunt foarte frecvente. Un serviciu poate fi lent pentru câteva momente, un pachet de rețea poate fi pierdut sau un API poate răspunde pentru scurt timp cu o eroare 500. O politică de retry ajută aplicația să încerce automat operațiunea din nou, în loc să eșueze imediat. Este una dintre cele mai simple și eficiente tehnici de reziliență.

Polly oferă mai multe moduri de a reîncerca operațiunile eșuate, cum ar fi:

Atunci când se folosește retry, ar trebui reîncercate doar erorile temporare (tranzitorii) — cum ar fi timeouts sau răspunsurile de tip "too many requests" (408, 429, 502, 503, 504). De asemenea, ar trebui reîncercate doar operațiunile idempotente, adică acele operațiuni care pot fi repetate fără a produce efecte secundare nedorite. Pentru a evita agravarea problemei, de folosit întotdeauna exponential backoff și jitter, astfel încât reîncercările să nu se întâmple toate în același timp.

B. Politica Circuit Breaker

Modelul Circuit Breaker protejează sistemul de apeluri repetate către un serviciu care deja eșuează. În loc să tot încerci la nesfârșit și să se agraveze problema, circuit breakerul "se deschide" după un anumit număr de erori și blochează temporar apelurile către serviciul nesănătos. Acest lucru oferă timp serviciului defect să își revină și împiedică aplicația să irosească resurse pe apeluri care aproape sigur vor eșua.

O modalitate simplă de a înțelege aceste doua concepte este: Retry ajută atunci când un serviciu este temporar lent. Circuit breaker ajută atunci când un serviciu este constant defect.

Când circuitul este deschis, apelurile eșuează imediat. După o perioadă de răcire, circuitul trece în starea "halfopen", permițând câteva apeluri de test. Dacă acestea reușesc, circuitul se închide din nou și traficul revine la normal.

Acest model este esențial în sistemele distribuite deoarece previne "defecțiunile în cascadă", situații în care un singur serviciu defect provoacă eșecuri în lanț în multe alte servicii din cauza încărcării suplimentare.

C. Politica Timeout

Nu lăsa serviciile lente să te încetinească.

O politică de timeout protejează aplicația de situațiile în care se așteaptă prea mult după un răspuns. În sistemele distribuite, un serviciu lent poate fi la fel de dăunător ca unul care eșuează complet. Dacă aplicația așteaptă la nesfârșit, threadurile se blochează, cozile se umplu, iar întregul sistem devine brusc lent.

O politică de timeout rezolvă această problemă prin regula:

"Dacă acest apel nu răspunde în X secunde, oprește așteptarea și eșuează rapid."

Astfel, sistemul rămâne receptiv și previne consumul excesiv de resurse.

Când să folosim timeouturi:

D. Politica Bulkhead

Nu lăsa o singură defecțiune să scufunde întreaga "navă". Modelul Bulkhead provine din construcția navelor. Navele sunt împărțite în compartimente ("bulkheads"), astfel încât, dacă unul se inundă, întreaga navă nu se scufundă.

În sistemele distribuite, un bulkhead limitează câte apeluri simultane pot fi făcute către o anumită dependență. Dacă un serviciu devine lent sau supraîncărcat, nu se dorește ca toate threadurile tale să rămână blocate așteptând după el.

Un bulkhead spune practic:

"Doar X apeluri către acest serviciu în același timp. Restul eșuează rapid."

Acest lucru protejează restul sistemului de a fi tras în jos de un singur serviciu problematic.

Combinarea politicilor

Platforma .NET 10 include un pipeline de reziliență integrat, bazat pe Polly, care aplică automat politicile în ordinea corectă atunci când folosim HttpClientFactory și AddStandardResilienceHandler.

Într-un sistem distribuit real, o singură politică nu este suficientă.

O politică de retry poate ajuta în cazul erorilor temporare, dar nu te protejează de apeluri lente. Un timeout previne blocarea pe operațiuni care durează prea mult, dar nu împiedică supraîncărcarea unui serviciu deja instabil. Un circuit breaker oprește apelurile către un serviciu care eșuează constant, dar nu reîncearcă atunci când problema este tranzitorie. Iar un bulkhead limitează câte cereri simultane pot intra, prevenind blocarea resurselor.

De aceea, .NET 10 combină automat aceste politici într-un pipeline coerent și predictibil. Practic, platforma "stivuiește" politicile astfel încât fiecare să protejeze sistemul de un alt tip de problemă.

O combinație tipică arată astfel:

Ordinea corectă a strategiilor

.NET 10 aplică politicile în această ordine:

  1. Bulkhead (cel mai exterior strat),

  2. Timeout,

  3. Retry,

  4. Circuit Breaker (cel mai interior strat).

Această ordine asigură că:

Folosirea Polly cu HttpClientFactory în .NET 10+

În aplicațiile moderne .NET, metoda recomandată pentru a face apeluri HTTP este prin HttpClientFactory, care este integrat direct în sistemul de dependency injection (DI).

Acesta este un mare avantaj: se obține reziliență de nivel enterprise cu doar câteva linii de cod și se evita greșelile comune, cum ar fi crearea excesivă de instanțe HttpClient sau ordonarea greșită a politicilor.

Folosirea HttpClientFactory împreună cu handlerul de reziliență integrat îți oferă:

Aceasta este metoda standard pentru a construi microservicii reziliente în .NET 10+.

Un exemplu simplu de configurare a unui client HTTP rezilient folosind pipelineul integrat Polly:

builder.Services.AddHttpClient("WeatherApi")
.AddStandardResilienceHandler(options =>
  {
    // Retry cu exponential backoff + jitter
    options.Retry.MaxRetryAttempts = 3;
    options.Retry.Delay = TimeSpan
    .FromMilliseconds(200);

    options.Retry.UseJitter = true;

    // Timeout pentru fiecare încercare
    options.Timeout.Timeout = TimeSpan
    .FromSeconds(3);

    // Circuit breaker pentru erori persistente
    options.CircuitBreaker.FailureThreshold = 0.5;
    options.CircuitBreaker.SamplingDuration = 
      TimeSpan.FromSeconds(30);

    options.CircuitBreaker.MinimumThroughput = 10;
    options.CircuitBreaker.BreakDuration = TimeSpan
    .FromSeconds(15);

    // Bulkhead pentru limitarea concurenței
    options.Bulkhead.MaxConcurrentRequests = 10;
    options.Bulkhead.QueueLimit = 5;
 });

După înregistrare, injectezi clientul în serviciul tău:

public class WeatherService(HttpClient client)
{
  public async Task GetForecastAsync()
  {
   var response = await client.GetAsync("/forecast");
   response.EnsureSuccessStatusCode();
   return await response.Content.ReadAsStringAsync();
  }
}

Și îl înregistrezi astfel:

builder.Services.AddTransient<WeatherService>();
builder.Services.AddHttpClient<WeatherService>("WeatherApi");

Acum fiecare apel către Weather API folosește automat pipelineul de reziliență configurat.

Cum funcționează?

a. Retry - reîncearcă automat apelurile temporar eșuate.

Configurația:

Rezultat: reîncearcă inteligent, fără să supraîncarce serviciul.

b. Timeout - nu aștepta la nesfârșit.

c. Circuit Breaker - oprește apelurile către un serviciu care eșuează constant.

d. Bulkhead - limitează câte cereri simultane pot intra.

Cazuri reale de utilizare în producție

APIuri externe lente sau instabile.

Serviciile terțe (plăți, geolocație, SMS, email, hărți etc.) pot deveni lente în orele de vârf. Fără timeout și retry:

Cu reziliență configurată:

Rate limiting (429 Too Many Requests)

Multe APIuri moderne limitează numărul de cereri pe minut. Când primești 429:

3. Baze de date sau cacheuri care răspund greu:

Uneori baza de date sau Redis poate avea spikeuri de latență. În aceste situații:

4. Operațiuni idempotente care pot fi reîncercate în siguranță:

Greșeli de producție

1. Folosirea retryurilor fără timeout.

2. Retry pe operațiuni care nu sunt idempotente

Acest lucru poate duce la:

  1. dublarea comenzilor;

  2. tranzacții duplicate;

  3. inconsistență în date.

4. Lipsa unui circuit breaker

Fără circuit breaker:

În dezvoltarea de aplicații distribuite, reziliența nu este un "nicetohave", ci o necesitate. Politicile Retry, Circuit Breaker, Timeout și Bulkhead te ajută să construiești servicii stabile, capabile să gestioneze erori, latențe și dependențe imprevizibile. Înțelegerea și aplicarea lor te vor ajuta să scrii aplicații mai robuste, mai rapide și mai ușor de întreținut. Aceste patternuri nu doar rezolvă probleme, ci te pregătesc și pentru a gândi ca un programator orientat spre fiabilitate.