RO EN

Reziliență în .NET (1/6): de ce cad sistemele distribuite și ce aduce Polly v8

Reziliență în .NET (1/6): de ce cad sistemele distribuite și ce aduce Polly v8 ✨ Imagine generată cu AI
Doru Bulubașa
30 septembrie 2026
36 vizualizări

Ultima zi a lunii, 23:40. Aplicația de facturare trimite în lot documentele către un serviciu extern. Serviciul nu cade — ar fi fost prea simplu. Doar răspunde în 40 de secunde în loc de 400 de milisecunde. Zece minute mai târziu, nici API-ul tău nu mai răspunde la nimic: thread pool-ul e plin de request-uri care așteaptă, liveness probe-ul dă timeout, orchestratorul repornește podurile, iar podurile noi se umplu din nou în câteva secunde.

Niciun bug în cod. Nicio excepție ratată. Doar o dependență lentă și o aplicație care n-a fost construită să spună „nu mai aștept”.

Despre asta e seria de față: cum construiești aplicații .NET care rămân în picioare atunci când lumea din jurul lor se clatină. Șase articole, de la fundamente până la chaos engineering. În primul punem bazele: ce tipuri de eșecuri există, de ce latența e mai periculoasă decât erorile și cum arată Polly v8 — biblioteca pe care o folosim în toată seria.

Rețeaua nu e de încredere. Niciodată.

În anii ’90, câțiva ingineri de la Sun Microsystems au formulat ceea ce azi numim fallacies of distributed computing: opt presupuneri pe care orice programator le face, conștient sau nu, și care sunt toate false. Primele trei sunt suficiente ca să dărâme o arhitectură: rețeaua e fiabilă, latența e zero, lățimea de bandă e infinită.

Într-un monolit clasic, un apel de metodă durează nanosecunde și fie reușește, fie aruncă o excepție clară. Când același apel trece peste rețea — către o bază de date, un API extern, un alt microserviciu, un cache distribuit — apare o a treia posibilitate, cea mai neplăcută: nu știi. Cererea poate să fi ajuns sau nu. Poate să fi fost procesată sau nu. Răspunsul poate să vină peste 50 ms, peste 50 de secunde sau niciodată.

Reziliența nu înseamnă să elimini aceste situații. Înseamnă să decizi dinainte ce face aplicația ta când apar.

Taxonomia eșecurilor

Primul pas nu e să instalezi un pachet NuGet, ci să înțelegi ce fel de eșec tratezi. Strategia greșită aplicată pe tipul greșit de eșec face mai mult rău decât lipsa oricărei strategii.

  • Eșecuri tranzitorii — conexiune resetată, 503 în timpul unui deploy, 429 din throttling, un deadlock SQL, un failover de bază de date în cloud. Dispar singure în câteva sute de milisecunde sau secunde. Aici retry-ul are sens.
  • Eșecuri permanente — 400 Bad Request, 401, 404, un contract de API schimbat, date invalide. Oricâte reîncercări ai face, rezultatul e același. Aici retry-ul doar consumă resurse și amână eroarea.
  • Latență și degradare — serviciul trăiește, dar răspunde de zece ori mai încet. Nu primești nicio eroare, doar aștepți. E cel mai periculos tip, pentru că nimic nu „pică” vizibil până nu pică totul.
  • Suprasolicitare — dependența e în viață, dar sufocată. Fiecare cerere în plus, inclusiv retry-urile tale bine intenționate, o împinge mai adânc. Aici ai nevoie să reduci presiunea, nu s-o crești: circuit breaker, rate limiting, degradare controlată.

Observă că doar prima categorie se rezolvă cu reîncercări. Pentru celelalte trei, reflexul „mai încearcă o dată” e exact reflexul greșit.

De ce latența e mai periculoasă decât erorile

Un serviciu care pică repede e un vecin bun: primești eroarea în 5 ms, o tratezi, mergi mai departe. Un serviciu care atârnă e un vecin toxic, iar legea lui Little explică de ce.

Numărul de cereri aflate simultan în sistem este egal cu rata de sosire înmulțită cu timpul petrecut de fiecare cerere în sistem (L = λ × W). La 200 de cereri pe secundă și 50 ms per apel către o dependență, ai în medie 10 cereri concurente care așteaptă. Dacă dependența urcă la 30 de secunde, ai 6.000.

Fiecare dintre ele ține memorie alocată, o conexiune din pool, un socket, poate o tranzacție deschisă. Thread pool-ul .NET începe să injecteze thread-uri noi, lent, iar între timp cererile care n-au nicio legătură cu dependența lentă — un simplu GET /produse — stau și ele la coadă. Așa se naște un cascading failure: o problemă locală într-un singur serviciu devine o problemă globală în toată platforma.

De aceea prima strategie de reziliență pe care o adaugi nu e retry-ul, ci timeout-ul. Timeout-ul transformă o latență nelimitată într-un eșec rapid și previzibil. Un eșec rapid se poate trata. O așteptare infinită, nu.

Polly v8: ce s-a schimbat

Polly e de ani buni standardul de facto pentru reziliență în .NET. Versiunea 8 a fost o rescriere aproape completă, făcută în colaborare cu echipa .NET de la Microsoft, iar pachetul oficial Microsoft.Extensions.Http.Resilience este construit direct peste ea. Dacă ai cod scris pe Polly 7, conceptele rămân, dar API-ul arată diferit.

  • Policy devine strategy. Retry, circuit breaker, timeout, fallback, hedging, rate limiter — toate sunt resilience strategies.
  • PolicyWrap devine ResiliencePipeline. Compui strategiile într-un pipeline, iar ordinea în care le adaugi este ordinea în care se execută, din exterior spre interior.
  • Un singur API. Nu mai există separarea între politici sync și async. Totul e construit în jurul ValueTask, cu alocări minime pe calea fericită.
  • Opțiuni tipizate și validate. Fiecare strategie se configurează printr-un obiect de opțiuni (RetryStrategyOptions, CircuitBreakerStrategyOptions etc.), validat la construirea pipeline-ului.
  • Telemetrie integrată. Loguri și metrici prin System.Diagnostics.Metrics, fără cod suplimentar.

Diferența se vede imediat. Același retry, în Polly 7:

var policy = Policy
    .Handle<HttpRequestException>()
    .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));

Și în Polly 8:

var pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>(),
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true
    })
    .Build();

Mai verbos, dar explicit: fiecare parametru are un nume, iar jitter-ul — despre care vorbim pe larg în articolul următor — e o simplă proprietate, nu o formulă scrisă de mână. Dacă folosești încă Microsoft.Extensions.Http.Polly cu AddTransientHttpErrorPolicy, Microsoft recomandă migrarea la noul pachet de reziliență.

Primul pipeline: un timeout făcut corect

Pachetul de bază este Polly.Core:

dotnet add package Polly.Core
using Polly;
using Polly.Timeout;

ResiliencePipeline pipeline = new ResiliencePipelineBuilder()
    .AddTimeout(TimeSpan.FromSeconds(3))
    .Build();

try
{
    ExchangeRates rates = await pipeline.ExecuteAsync(
        async token => await exchangeRateService.GetRatesAsync(token),
        cancellationToken);
}
catch (TimeoutRejectedException)
{
    logger.LogWarning("Serviciul de curs valutar nu a răspuns în 3 secunde.");
}

Pare banal, dar există o capcană pe care o calcă aproape toată lumea la început. Uită-te la varianta asta:

// GREȘIT: token-ul primit de la Polly este ignorat
await pipeline.ExecuteAsync(
    async _ => await exchangeRateService.GetRatesAsync(),
    cancellationToken);

În Polly v8, timeout-ul este cooperativ. Când expiră cele 3 secunde, Polly anulează CancellationToken-ul pe care ți l-a dat în callback — și atât. Nu omoară thread-uri, nu abandonează task-ul. Dacă operația ta nu propagă token-ul până la I/O-ul real (HttpClient, EF Core, driverul de Cosmos DB, Task.Delay), ea continuă să ruleze, iar tu primești TimeoutRejectedException abia după 40 de secunde, când operația se termină singură. Adică exact problema pe care voiai s-o rezolvi.

Regula de aur, valabilă pentru toată seria: token-ul primit de la pipeline trebuie transmis până la ultimul apel asincron. Fără asta, niciun timeout, oricât de bine configurat, nu te protejează.

Pipeline-uri în dependency injection

Construirea pipeline-urilor ad-hoc e utilă pentru experimente. Într-o aplicație reală le înregistrezi în DI, cu nume, și le refolosești. Pachetul este Polly.Extensions:

dotnet add package Polly.Extensions
builder.Services.AddResiliencePipeline("curs-valutar", pipeline =>
{
    pipeline.AddTimeout(TimeSpan.FromSeconds(3));
});

Și îl consumi prin ResiliencePipelineProvider<string>:

public sealed class ExchangeRateReader(
    ResiliencePipelineProvider<string> pipelineProvider,
    IExchangeRateService exchangeRateService)
{
    private readonly ResiliencePipeline _pipeline =
        pipelineProvider.GetPipeline("curs-valutar");

    public async Task<ExchangeRates> ReadAsync(CancellationToken cancellationToken) =>
        await _pipeline.ExecuteAsync(
            async token => await exchangeRateService.GetRatesAsync(token),
            cancellationToken);
}

Câștigul nu e doar estetic. Pipeline-urile înregistrate în DI primesc automat ILoggerFactory și metrici, sunt singleton-uri (starea unui circuit breaker trebuie să fie partajată, altfel nu are niciun sens) și pot fi configurate centralizat.

Pentru HttpClient: Microsoft.Extensions.Http.Resilience

Majoritatea apelurilor externe dintr-o aplicație ASP.NET Core trec prin HttpClient. Pentru ele, Microsoft oferă un pachet dedicat, care aduce un pipeline complet, preconfigurat cu valori rezonabile:

dotnet add package Microsoft.Extensions.Http.Resilience
builder.Services
    .AddHttpClient<ISupplierClient, SupplierClient>(client =>
    {
        client.BaseAddress = new Uri("https://api.furnizor.example/");
    })
    .AddStandardResilienceHandler();

O singură linie, cinci strategii. Merită să știi exact ce ai primit, în ordinea execuției, din exterior spre interior:

  1. Rate limiter — maximum 1.000 de cereri concurente, fără coadă. Protejează dependența (și propriul proces) de un val necontrolat.
  2. Total request timeout — 30 de secunde pentru toată operația, inclusiv toate reîncercările.
  3. Retry — până la 3 reîncercări, backoff exponențial cu jitter, pornind de la 2 secunde. Tratează erorile 5xx, 408, 429, HttpRequestException și timeout-urile per încercare.
  4. Circuit breaker — se deschide când peste 10% din cereri eșuează, cu minimum 100 de cereri într-o fereastră de 30 de secunde, și rămâne deschis 5 secunde.
  5. Attempt timeout — 10 secunde pentru fiecare încercare în parte.

Gândește-te la pipeline ca la o ceapă. Timeout-ul total stă în afara retry-ului, deci pune o limită fermă pe întreaga operație, indiferent câte reîncercări se fac. Timeout-ul per încercare stă în interiorul retry-ului, deci o încercare blocată e tăiată după 10 secunde și numărată ca eșec — atât de retry, cât și de circuit breaker. Dacă ai inversa ordinea, un singur timeout de 30 de secunde ar înghiți toate reîncercările, iar circuit breaker-ul n-ar vedea niciodată eșecurile individuale.

Ajustarea valorilor implicite

Valorile implicite sunt un punct de plecare, nu un răspuns universal. Un API intern care răspunde de obicei în 80 ms nu are nevoie de un timeout de 10 secunde per încercare:

builder.Services
    .AddHttpClient<ISupplierClient, SupplierClient>(client =>
    {
        client.BaseAddress = new Uri("https://api.furnizor.example/");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
        options.Retry.MaxRetryAttempts = 2;
        options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(15);
    });

Opțiunile sunt validate la pornire, iar două reguli te vor prinde sigur la un moment dat: timeout-ul total trebuie să fie mai mare decât cel per încercare, iar fereastra de eșantionare a circuit breaker-ului trebuie să fie cel puțin dublul timeout-ului per încercare. Dacă le încalci, aplicația aruncă excepție la prima rezolvare a clientului, nu în producție la trei dimineața — exact cum trebuie.

Două detalii practice:

  • HttpClient.Timeout rămâne activ. Implicit are 100 de secunde și învelește tot lanțul de handler-e. Dacă setezi un timeout total mai mare de 100 de secunde, cel de la HttpClient câștigă. Păstrează-l întotdeauna peste timeout-ul total al pipeline-ului.
  • Retry-ul pe POST poate fi periculos. Dacă endpoint-ul apelat nu e idempotent, o reîncercare poate crea o factură dublă. Pentru astfel de clienți există options.Retry.DisableForUnsafeHttpMethods(). Discutăm idempotența în detaliu în articolul următor.

Când pipeline-ul standard nu se potrivește deloc, construiești unul propriu cu AddResilienceHandler("nume", builder => { ... }), folosind exact aceleași strategii ca în Polly.Core.

Observabilitate din prima zi

O strategie de reziliență pe care n-o poți vedea în acțiune e o strategie în care doar speri. Polly v8 emite metrici printr-un Meter numit Polly (evenimente de retry, deschideri de circuit, durata încercărilor și a pipeline-ului). Dacă folosești OpenTelemetry, e suficient să le adaugi:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddMeter("Polly"));

De aici încolo, un grafic cu numărul de reîncercări pe minut îți spune despre sănătatea unei dependențe mai mult decât orice status page al furnizorului. Un retry rate care crește constant e un semnal de alarmă cu mult înainte ca utilizatorii să simtă ceva.

Ce NU rezolvă reziliența

Merită spus clar înainte să mergem mai departe, pentru că Polly e ușor de folosit greșit:

  • Nu repară bug-uri. Un NullReferenceException reîncercat de trei ori e tot un NullReferenceException, doar mai scump.
  • Nu înlocuiește capacitatea. Dacă o dependență e subdimensionată, reziliența te ajută să cazi elegant, nu să nu cazi.
  • Se multiplică între straturi. Dacă gateway-ul face 3 reîncercări, serviciul din spate face 3 și clientul HTTP din acel serviciu face încă 3, un singur eșec poate genera până la 64 de apeluri către dependența finală. Reziliența se proiectează la nivel de sistem, nu se adaugă reflex în fiecare strat.

Recapitulare

  • Clasifică eșecul înainte să alegi strategia: tranzitoriu, permanent, latență sau suprasolicitare.
  • Fiecare apel peste rețea are un timeout. Fără excepții.
  • Timeout-ul în Polly v8 e cooperativ: CancellationToken-ul trebuie propagat până la I/O.
  • Înregistrează pipeline-urile în DI, nu le construi ad-hoc.
  • Pentru HttpClient, pornește de la AddStandardResilienceHandler() și ajustează valorile la profilul real al dependenței.
  • Activează metricile Polly din prima zi.

Ce urmează în serie

  1. Fundamente și Polly v8 — articolul de față
  2. Retry policies inteligente — backoff, jitter, idempotență și retry storms
  3. Circuit breaker și bulkhead — cum oprești o cădere să se propage
  4. Health checks avansate — /health/live vs. /health/ready și de ce contează diferența
  5. Graceful degradation — fallback, cache, hedging și ce face aplicația când o dependență e jos
  6. Chaos engineering basics — cum demonstrezi că toate cele de mai sus chiar funcționează

În articolul următor intrăm în detaliile retry-ului: de ce jitter-ul nu e opțional, ce se poate reîncerca în siguranță și cum arată un retry storm care îți dărâmă singur propria infrastructură.