RO EN

Reziliență în .NET (2/6): retry policies inteligente cu Polly v8

Reziliență în .NET (2/6): retry policies inteligente cu Polly v8 ✨ Imagine generată cu AI
Doru Bulubașa
02 octombrie 2026
31 vizualizări

Luni dimineața, 9:02. Baza de date face un failover planificat, care durează 20 de secunde. Nimic grav: pentru asta există retry-uri. Doar că retry-uri există peste tot. API Gateway-ul reîncearcă de 3 ori. Serviciul de comenzi reîncearcă de 3 ori. Repository-ul din serviciul de comenzi reîncearcă și el de 3 ori. Fiecare cerere a unui utilizator devine până la 64 de apeluri către o bază de date care oricum nu răspunde.

La 9:02:20 baza de date revine. Exact atunci o lovește un val sincronizat de cereri, toate reîncercate după aceeași pauză de 2 secunde, apoi de 4, apoi de 8. Cade din nou. Failover-ul de 20 de secunde devine un incident de 40 de minute.

Retry-ul este cea mai simplă strategie de reziliență și, din același motiv, cea mai ușor de folosit greșit. Un retry bun recuperează un eșec tranzitoriu fără ca utilizatorul să observe ceva. Un retry prost transformă o problemă mică într-un atac asupra propriei infrastructuri. În acest al doilea articol din serie vedem ce face diferența.

Prima întrebare: merită reîncercat?

În primul articol am clasificat eșecurile în patru categorii. Retry-ul are sens doar pentru una dintre ele: eșecurile tranzitorii. Primul lucru pe care îl configurezi nu e numărul de reîncercări, ci predicatul care decide ce se reîncearcă.

  • Da: HttpRequestException (conexiune refuzată sau resetată, DNS), TimeoutRejectedException de la timeout-ul per încercare, 408 Request Timeout, 429 Too Many Requests, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout.
  • Discutabil: 500 Internal Server Error. Uneori e tranzitoriu (un deadlock în spatele API-ului), alteori e un bug care va eșua identic de fiecare dată. Handler-ul standard din Microsoft.Extensions.Http.Resilience îl reîncearcă. Pentru API-uri pe care le cunoști bine, poți decide altfel.
  • Nu: 400, 401, 403, 404, 409, 422. Cererea e greșită, nu rețeaua. Reîncercarea doar amână eroarea și consumă resurse.

În Polly v8, predicatul se scrie cu PredicateBuilder. Pentru apeluri HTTP folosești varianta generică, care poate inspecta și rezultatul, nu doar excepțiile:

static bool IsTransient(HttpResponseMessage response) =>
    response.StatusCode is HttpStatusCode.RequestTimeout
        or HttpStatusCode.TooManyRequests
        or HttpStatusCode.InternalServerError
        or HttpStatusCode.BadGateway
        or HttpStatusCode.ServiceUnavailable
        or HttpStatusCode.GatewayTimeout;

var shouldHandle = new PredicateBuilder<HttpResponseMessage>()
    .Handle<HttpRequestException>()
    .Handle<TimeoutRejectedException>()
    .HandleResult(IsTransient);

Backoff: cât aștepți între încercări

Un retry imediat după eșec e aproape întotdeauna inutil. Dacă serviciul a răspuns cu 503 acum 3 milisecunde, șansele să fie sănătos acum sunt minime. Ai nevoie de o pauză, iar Polly v8 oferă trei tipuri prin DelayBackoffType:

  • Constant — aceeași pauză de fiecare dată: 1s, 1s, 1s. Util pentru polling sau pentru operații locale foarte scurte.
  • Linear — pauza crește cu un pas fix: 1s, 2s, 3s.
  • Exponential — pauza se dublează: 1s, 2s, 4s, 8s. Varianta potrivită pentru aproape orice dependență externă, pentru că dă serviciului din spate tot mai mult timp să-și revină pe măsură ce problema pare mai serioasă.

Backoff-ul exponențial are o problemă evidentă: crește foarte repede. A zecea reîncercare cu bază de 1 secundă ar aștepta peste 8 minute. De aceea există MaxDelay, care pune un plafon pe orice pauză, indiferent de formulă.

Jitter: de ce nu e opțional

Întoarce-te la scenariul de la început. O mie de instanțe de client primesc eroare în aceeași secundă, pentru că dependența a căzut pentru toată lumea deodată. Toate au același backoff exponențial fără jitter. Ce se întâmplă?

Toate reîncearcă exact după 1 secundă. Apoi toate exact după încă 2. Apoi toate exact după încă 4. Încărcarea nu se distribuie în timp, ci vine în valuri sincronizate, fiecare cu toată forța celor o mie de clienți. E fenomenul numit thundering herd. Serviciul care încearcă să-și revină primește cea mai mare lovitură exact în momentul în care e cel mai fragil.

Jitter-ul adaugă o componentă aleatoare fiecărei pauze, astfel încât reîncercările clienților se împrăștie în timp în loc să se suprapună. Valul de 1.000 de cereri în aceeași milisecundă devine un flux de câteva sute de cereri pe secundă, pe care un serviciu în recuperare îl poate absorbi.

În Polly v8, jitter-ul e o singură proprietate. Pentru backoff exponențial, Polly folosește un algoritm de tip decorrelated jitter, care păstrează creșterea exponențială în medie, dar distribuie pauzele uniform, fără grupări:

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = shouldHandle,
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        Delay = TimeSpan.FromMilliseconds(500),
        MaxDelay = TimeSpan.FromSeconds(10),
        UseJitter = true
    })
    .Build();

Regula e simplă: dacă un retry poate fi executat simultan de mai mulți clienți, și aproape întotdeauna poate, UseJitter = true. Nu există aproape niciun scenariu de producție în care să vrei reîncercări sincronizate.

Retry-After: când serviciul îți spune cât să aștepți

Unele servicii îți spun explicit când să revii. Un 429 de la un API cu rate limiting sau un 503 în timpul unei mentenanțe vin adesea cu header-ul Retry-After, fie ca număr de secunde, fie ca dată. Să-l ignori și să reîncerci după propriul backoff înseamnă să lovești din nou un serviciu care ți-a spus clar că nu te poate servi încă. În cazul unor API-uri publice, asta poate duce și la blocarea temporară a cheii de acces.

Handler-ul standard din Microsoft.Extensions.Http.Resilience respectă deja acest header (ShouldRetryAfterHeader este activ implicit). Când construiești pipeline-ul manual, folosești DelayGenerator:

DelayGenerator = args =>
{
    if (args.Outcome.Result?.Headers.RetryAfter is { } retryAfter)
    {
        TimeSpan? delay = retryAfter.Delta
            ?? (retryAfter.Date is { } date ? date - DateTimeOffset.UtcNow : null);

        if (delay > TimeSpan.Zero)
        {
            return ValueTask.FromResult(delay);
        }
    }

    // null = folosește backoff-ul configurat
    return ValueTask.FromResult<TimeSpan?>(null);
}

Un singur lucru de verificat: dacă serviciul îți cere să aștepți 60 de secunde, iar timeout-ul total al operației e de 10, retry-ul nu mai are rost. În astfel de cazuri e mai sănătos să eșuezi imediat și să lași un strat superior, de exemplu o coadă de mesaje, să reia operația mai târziu.

Idempotența: întrebarea pe care trebuie s-o pui înainte de orice retry

Să zicem că trimiți o factură către un API extern. Cererea pleacă, serverul o procesează, salvează factura, iar răspunsul se pierde pe drum din cauza unui reset de conexiune. Pentru tine e un HttpRequestException, un eșec tranzitoriu clasic. Reîncerci. Serverul primește a doua cerere și creează a doua factură.

Problema nu e retry-ul în sine. Problema e că ai reîncercat o operație care nu e idempotentă. O operație e idempotentă dacă executarea ei de mai multe ori are același efect ca executarea o singură dată.

  • Prin semantica HTTP, GET, HEAD, PUT, DELETE și OPTIONS sunt idempotente. Asta presupune că API-ul respectă semantica, lucru care merită verificat, nu presupus.
  • POST și PATCH nu sunt idempotente în mod implicit.

Pentru clienții care trimit operații nesigure, handler-ul standard permite dezactivarea retry-ului pe metodele respective:

builder.Services
    .AddHttpClient<IInvoiceClient, InvoiceClient>()
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.DisableForUnsafeHttpMethods(); // POST, PATCH, PUT, DELETE, CONNECT
    });

Soluția mai bună, atunci când controlezi și serverul, este să faci operația idempotentă.

Idempotency-Key

Modelul folosit de Stripe, de multe API-uri bancare și formalizat într-un draft IETF e simplu: clientul generează o cheie unică pentru operația de business și o trimite într-un header. Serverul ține minte cheile procesate și, la o cerere repetată, returnează răspunsul salvat în loc să execute din nou operația.

var request = new HttpRequestMessage(HttpMethod.Post, "api/facturi")
{
    Content = JsonContent.Create(factura)
};

// Cheia identifică operația, nu încercarea: se generează O SINGURĂ DATĂ
request.Headers.Add("Idempotency-Key", factura.Id.ToString());

var response = await httpClient.SendAsync(request, cancellationToken);

Detaliul critic e în comentariu. Dacă generezi cheia în interiorul logicii de retry, fiecare încercare are o cheie nouă și protecția dispare. Cel mai sigur e să derivezi cheia dintr-un identificator care există deja, cum ar fi ID-ul facturii generat la creare. Handler-ul de reziliență retrimite același HttpRequestMessage, deci header-ul se păstrează la fiecare încercare.

Pe server ai nevoie de o tabelă în care cheia are o constrângere de unicitate:

CREATE TABLE IdempotencyKeys
(
    TenantId        UNIQUEIDENTIFIER NOT NULL,
    IdempotencyKey  NVARCHAR(100)    NOT NULL,
    RequestHash     VARBINARY(32)    NOT NULL,
    StatusCode      INT              NULL,   -- NULL = în procesare
    ResponseBody    NVARCHAR(MAX)    NULL,
    CreatedAt       DATETIME2        NOT NULL,
    CONSTRAINT PK_IdempotencyKeys PRIMARY KEY (TenantId, IdempotencyKey)
);

Fluxul la primirea unei cereri:

  1. Încerci să inserezi cheia cu StatusCode = NULL. Constrângerea de unicitate face pasul atomic, chiar dacă două cereri identice sosesc în aceeași milisecundă.
  2. Dacă inserarea reușește, ești primul. Execuți operația, apoi salvezi codul de stare și răspunsul.
  3. Dacă inserarea eșuează pe cheie duplicată și există un răspuns salvat, îl returnezi exact așa cum a fost trimis prima dată.
  4. Dacă cheia există, dar StatusCode e încă NULL, operația originală e încă în curs. Returnezi 409 Conflict, iar clientul va reîncerca mai târziu.
  5. Dacă cheia există, dar RequestHash diferă, clientul a refolosit cheia pentru altă cerere. Returnezi 422: e un bug pe partea clientului, nu un retry.

Cheile se pot șterge după 24–48 de ore. Niciun retry legitim nu durează atât.

Retry storms: când reziliența devine atac

Să revenim la cele 64 de apeluri de la început. Matematica e brutală: dacă fiecare strat face o încercare plus 3 reîncercări, fiecare strat înmulțește traficul cu 4. Trei straturi înseamnă 4 × 4 × 4 = 64. Exact când dependența finală are cea mai mare nevoie de liniște, primește de 64 de ori mai mult trafic decât în mod normal.

Trei reguli care previn asta:

  • Reîncearcă într-un singur strat. De regulă, stratul cel mai apropiat de dependența care eșuează. Straturile de deasupra propagă eroarea rapid, fără să o mai reîncerce.
  • Nu pune retry peste SDK-uri care fac deja retry. EF Core cu EnableRetryOnFailure, SDK-ul de Cosmos DB (care reîncearcă automat cererile cu 429), SDK-urile Azure: toate au propriile politici. Un pipeline Polly peste ele multiplică încercările fără să știi.
  • Folosește un retry budget. Limitează reîncercările la un procent din traficul total, nu doar la un număr pe cerere.

Retry budget: un plafon global

MaxRetryAttempts = 3 limitează reîncercările per cerere. Nu limitează reîncercările în total. Când 100% din cereri eșuează, fiecare va face toate cele 3 reîncercări, iar traficul crește de 4 ori.

Un retry budget rezolvă asta. Ideea vine din mecanismul de retry throttling din gRPC: un rezervor de jetoane care scade la fiecare eșec și crește încet la fiecare succes. Reîncercările sunt permise doar cât timp rezervorul e cel puțin pe jumătate plin. Când dependența e complet jos, rezervorul se golește repede și reîncercările se opresc singure. Când își revine, succesele îl umplu la loc.

public sealed class RetryBudget(double maxTokens = 10, double tokenRatio = 0.1)
{
    private readonly Lock _lock = new();
    private double _tokens = maxTokens;

    public void RecordSuccess()
    {
        lock (_lock) _tokens = Math.Min(maxTokens, _tokens + tokenRatio);
    }

    public bool TryRecordFailureAndAllowRetry()
    {
        lock (_lock)
        {
            _tokens = Math.Max(0, _tokens - 1);
            return _tokens > maxTokens / 2;
        }
    }
}

Integrarea în Polly se face direct în predicatul ShouldHandle, care este evaluat după fiecare încercare. Bugetul se înregistrează ca singleton, ca să fie partajat de toate cererile către aceeași dependență:

builder.Services.AddSingleton<RetryBudget>();

builder.Services.AddResiliencePipeline<string, HttpResponseMessage>("furnizor", (pipeline, context) =>
{
    var budget = context.ServiceProvider.GetRequiredService<RetryBudget>();

    pipeline.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        ShouldHandle = args =>
        {
            bool transient = args.Outcome switch
            {
                { Exception: HttpRequestException or TimeoutRejectedException } => true,
                { Result: { } response } => IsTransient(response),
                _ => false
            };

            if (!transient)
            {
                budget.RecordSuccess();
                return PredicateResult.False();
            }

            return ValueTask.FromResult(budget.TryRecordFailureAndAllowRetry());
        }
    });
});

Cu valorile implicite (10 jetoane, câte 0,1 pentru fiecare succes), reîncercările se opresc după aproximativ 5 eșecuri consecutive și revin după aproximativ 10 cereri reușite pentru fiecare jeton pierdut. Practic, reîncercările nu pot depăși cam 10% din traficul care reușește. Exemplul de mai sus tratează orice răspuns netranzitoriu ca succes; poți fi mai strict dacă vrei.

În articolul următor vedem un mecanism înrudit, dar mult mai radical: circuit breaker-ul, care nu doar oprește reîncercările, ci oprește cu totul apelurile către o dependență bolnavă.

Retry în EF Core: execution strategy

Pentru SQL Server, EF Core are propriul mecanism de retry, care cunoaște codurile de eroare tranzitorii specifice: deadlock-uri, failover Azure SQL, limite de resurse.

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sql =>
        sql.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: null)));

Capcana apare când folosești tranzacții explicite. Cu retry activat, codul următor aruncă InvalidOperationException, pentru că EF Core nu poate reîncerca singur doar o parte dintr-o tranzacție deschisă de tine:

// GREȘIT cu EnableRetryOnFailure
await using var tx = await db.Database.BeginTransactionAsync(ct);

Varianta corectă este să pui întreaga tranzacție în execution strategy, astfel încât un eșec să reia tot blocul de la început:

var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);

    db.Facturi.Add(factura);
    db.Outbox.Add(OutboxMessage.From(factura));
    await db.SaveChangesAsync(ct);

    await tx.CommitAsync(ct);
});

Pentru că blocul poate rula de mai multe ori, el trebuie să fie reluabil: fără efecte externe (emailuri, apeluri HTTP) în interiorul lui. Exact acesta e unul dintre motivele pentru care pattern-ul Outbox din exemplu funcționează atât de bine.

Bugetul de timp: retry-ul și timeout-ul trebuie să se înțeleagă

Revenim la configurarea din primul articol: timeout per încercare de 2 secunde, timeout total de 10 secunde, 3 reîncercări cu backoff exponențial de la 500 ms. Cel mai rău caz:

  • 4 încercări × 2 secunde = 8 secunde
  • pauze: aproximativ 0,5 + 1 + 2 = 3,5 secunde (variabile, din cauza jitter-ului)
  • total: aproximativ 11,5 secunde, deci peste timeout-ul total de 10 secunde

Nu e neapărat o greșeală, dar trebuie să fie o decizie conștientă: în cel mai rău caz, a treia reîncercare nu va avea loc niciodată, pentru că timeout-ul total taie operația înainte. Dacă vrei ca toate reîncercările să aibă șansa să ruleze, crește timeout-ul total sau scade numărul de reîncercări. Dacă utilizatorul așteaptă un răspuns în pagină, de obicei e mai bine invers: mai puține reîncercări și un eșec rapid, urmat de degradare controlată (subiectul articolului 5).

Observabilitate: loghează fiecare retry

Un retry reușit e invizibil pentru utilizator, ceea ce e bine. Dar dacă e invizibil și pentru tine, nu vei ști că o dependență se degradează până când retry-urile nu mai ajung. OnRetry e locul potrivit pentru un log structurat:

OnRetry = args =>
{
    logger.LogWarning(
        "Retry {Attempt} după {Delay} ms. Cauză: {Cause}",
        args.AttemptNumber + 1,
        args.RetryDelay.TotalMilliseconds,
        args.Outcome.Exception?.GetType().Name
            ?? ((int?)args.Outcome.Result?.StatusCode)?.ToString());

    return default;
}

Combinat cu metricile Polly din OpenTelemetry, ai un indicator foarte bun pentru sănătatea dependențelor: raportul dintre reîncercări și cereri. Când crește de la 0,1% la 5%, ai o problemă care încă nu se vede în erori, dar se va vedea curând.

Recapitulare

  • Reîncearcă doar eșecurile tranzitorii. Predicatul ShouldHandle e cea mai importantă parte a configurării.
  • Backoff exponențial, cu MaxDelay și întotdeauna cu UseJitter = true.
  • Respectă Retry-After când serviciul îl trimite.
  • Nu reîncerca operații care nu sunt idempotente. Fă-le idempotente cu un Idempotency-Key generat o singură dată per operație.
  • Reîncearcă într-un singur strat și nu dubla retry-urile SDK-urilor.
  • Un retry budget limitează reîncercările la nivel global, nu doar per cerere.
  • În EF Core, tranzacțiile explicite rulează în CreateExecutionStrategy().ExecuteAsync(...).
  • Verifică dacă suma încercărilor și a pauzelor încape în timeout-ul total.

Seria Reziliență în .NET

  1. Fundamente și Polly v8
  2. Retry policies inteligente — articolul de față
  3. Circuit breaker și bulkhead
  4. Health checks avansate
  5. Graceful degradation
  6. Chaos engineering basics

În articolul următor ne uităm la ce faci când reîncercările nu mai ajută: circuit breaker-ul, cu stările Closed, Open și Half-Open, și bulkhead-ul, care împiedică o singură dependență bolnavă să consume toate resursele aplicației.