RO EN

Reziliență în .NET (5/6): graceful degradation, sau ce faci când o dependență e jos

Reziliență în .NET (5/6): graceful degradation, sau ce faci când o dependență e jos ✨ Imagine generată cu AI
Doru Bulubașa
09 octombrie 2026
33 vizualizări

Ultima zi lucrătoare a lunii, 15:30. Sistemul național e-Factura nu mai răspunde: mentenanță neanunțată sau suprasolicitare, nu contează. Două firme folosesc două aplicații de facturare diferite.

În prima aplicație, butonul Emite factura afișează un spinner timp de 30 de secunde, apoi o eroare roșie: „Serviciul ANAF nu răspunde. Încercați mai târziu.” Contabila mai încearcă de câteva ori, apoi sună la suport. Facturile lunii rămân neemise până seara.

În a doua aplicație, butonul răspunde instant. Factura e emisă, numerotată și salvată, iar lângă ea apare un mic badge galben: „În așteptare transmitere către ANAF”. Sus, un banner discret: „Transmiterea către SPV e temporar întârziată. Facturile sunt salvate și vor fi transmise automat.” La 18:40, când ANAF revine, toate facturile pleacă singure. Contabila a terminat lucrul la 16:00.

Ambele aplicații aveau aceeași dependență căzută. Doar una dintre ele fusese proiectată să funcționeze cu o dependență căzută. Despre asta e graceful degradation: nu cum eviți eșecurile, pe care le-am tratat în articolele anterioare, ci ce oferi utilizatorului atunci când, inevitabil, ele se produc.

Toate drumurile au dus aici

În ultimele trei articole am amânat mereu aceeași întrebare. Retry-ul se oprește după un număr de încercări. Circuit breaker-ul aruncă BrokenCircuitException în microsecunde. Health check-ul raportează Degraded. Toate aceste mecanisme îți dau o eroare rapidă și previzibilă. Niciunul nu-ți spune ce să faci cu ea.

Răspunsul nu e tehnic, ci de produs. Și începe cu o întrebare pe care trebuie să o pui pentru fiecare funcționalitate în parte: ce e cel mai util lucru pe care îl pot oferi utilizatorului dacă dependența X lipsește?

Clasifică funcționalitățile înainte să scrii cod

Degradarea controlată se proiectează, nu se improvizează în timpul incidentului. Primul pas e o simplă clasificare, pe care o faci împreună cu cine răspunde de produs:

  • Critice — fără ele aplicația nu are sens: emiterea facturii, autentificarea, plata. Pentru acestea cauți căi alternative, nu renunțări.
  • Importante — utile, dar pot funcționa cu date mai vechi sau într-o formă simplificată: cursul valutar, dashboard-ul cu statistici, căutarea avansată.
  • Opționale — pot dispărea temporar fără ca utilizatorul să piardă ceva esențial: recomandări, notificări în timp real, avatare, export PDF avansat.

Pentru fiecare funcționalitate scrii pe o linie: de ce depinde și ce se întâmplă când acea dependență cade. Tabelul rezultat devine documentul de referință pentru tot restul articolului. Fiecare strategie de mai jos răspunde unei linii din el.

Strategia 1: fallback cu Polly v8

Mecanismul de bază e strategia de fallback: când pipeline-ul eșuează, în loc să propagi excepția, returnezi un rezultat alternativ. Pentru funcționalitățile opționale, rezultatul alternativ e adesea pur și simplu „nimic”:

builder.Services.AddResiliencePipeline<string, IReadOnlyList<Produs>>("recomandari", pipeline =>
{
    pipeline
        .AddFallback(new FallbackStrategyOptions<IReadOnlyList<Produs>>
        {
            ShouldHandle = new PredicateBuilder<IReadOnlyList<Produs>>()
                .Handle<HttpRequestException>()
                .Handle<TimeoutRejectedException>()
                .Handle<BrokenCircuitException>(),
            FallbackAction = _ => Outcome.FromResultAsValueTask<IReadOnlyList<Produs>>([])
        })
        .AddRetry(retryOptions)
        .AddCircuitBreaker(breakerOptions)
        .AddTimeout(TimeSpan.FromSeconds(2));
});

Fallback-ul stă întotdeauna în exteriorul pipeline-ului. Vrei ca retry-ul și circuit breaker-ul să-și facă treaba mai întâi, iar fallback-ul să intervină doar când toate au eșuat. Codul care apelează pipeline-ul nu mai are nevoie de niciun try/catch: primește fie recomandări, fie o listă goală.

Strategia 2: ultima valoare bună cunoscută

Pentru funcționalitățile importante, o listă goală nu e suficientă. Cursul valutar e exemplul perfect: dacă serviciul BNR nu răspunde, aplicația nu poate afișa un curs inexistent, dar poate afișa ultimul curs obținut cu succes, marcat clar ca atare.

Pattern-ul se numește last known good și are două părți: salvezi fiecare răspuns reușit, iar în fallback îl servești pe cel salvat.

public sealed record CursValutar(
    string Moneda, decimal Valoare, DateOnly DataPublicarii, bool EsteVechi = false);

public sealed class LastKnownGoodStore(IDistributedCache cache)
{
    private static readonly DistributedCacheEntryOptions Retention = new()
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7)
    };

    public Task SetAsync<T>(string key, T value, CancellationToken ct) =>
        cache.SetStringAsync($"lkg:{key}", JsonSerializer.Serialize(value), Retention, ct);

    public async Task<T?> GetAsync<T>(string key, CancellationToken ct)
    {
        var json = await cache.GetStringAsync($"lkg:{key}", ct);
        return json is null ? default : JsonSerializer.Deserialize<T>(json);
    }
}
builder.Services.AddResiliencePipeline<string, CursValutar>("curs-bnr", (pipeline, context) =>
{
    var store = context.ServiceProvider.GetRequiredService<LastKnownGoodStore>();

    pipeline
        .AddFallback(new FallbackStrategyOptions<CursValutar>
        {
            ShouldHandle = new PredicateBuilder<CursValutar>()
                .Handle<HttpRequestException>()
                .Handle<TimeoutRejectedException>()
                .Handle<BrokenCircuitException>(),
            FallbackAction = async args =>
            {
                var ultimul = await store.GetAsync<CursValutar>("curs-eur", args.Context.CancellationToken);

                return ultimul is not null
                    ? Outcome.FromResult(ultimul with { EsteVechi = true })
                    : Outcome.FromException<CursValutar>(new CursIndisponibilException());
            }
        })
        .AddRetry(retryOptions)
        .AddCircuitBreaker(breakerOptions)
        .AddTimeout(TimeSpan.FromSeconds(3));
});

Iar în serviciul care consumă pipeline-ul, fiecare răspuns proaspăt actualizează valoarea salvată:

var curs = await _pipeline.ExecuteAsync(
    async token => await _bnrClient.GetCursEurAsync(token), ct);

if (!curs.EsteVechi)
{
    await _store.SetAsync("curs-eur", curs, ct);
}

return curs;

Trei detalii fac diferența între un fallback bun și unul periculos:

  • Marchează explicit datele vechi. Câmpul EsteVechi și DataPublicarii ajung până în interfață: „Curs BNR din 08.10.2026 — cursul de azi nu este încă disponibil”. Utilizatorul decide în cunoștință de cauză.
  • Pune o limită de vechime. Un curs de acum o zi e acceptabil pentru afișare. Unul de acum o lună, probabil nu. Retenția de 7 zile din exemplu e o decizie de business, nu una tehnică.
  • Când nu ai nimic bun, eșuează. Dacă nu există nicio valoare salvată, fallback-ul aruncă o excepție clară. Nu inventează un curs.

Un fallback greșit e mai rău decât o eroare

Ultimul punct merită subliniat. E tentant să scrii un fallback care returnează ceva: un curs de 1, un preț de 0, o listă de produse goală într-un context în care lista goală are o semnificație. O factură emisă cu un curs de 1 leu pentru un euro nu e degradare controlată. E o factură greșită, cu consecințe fiscale, generată automat și în tăcere.

Regula: un fallback poate oferi date mai vechi sau mai puține, marcate ca atare. Nu poate oferi date false. Când singura alternativă la eroare e o valoare inventată, eroarea e alegerea corectă.

Strategia 3: decuplarea asincronă

Revenim la cele două aplicații de facturare de la început. Prima nu era prost scrisă. Probabil avea retry, timeout și poate și circuit breaker. Problema ei era de arhitectură: emiterea facturii și transmiterea către ANAF erau o singură operație sincronă. Când ANAF cădea, nicio strategie de reziliență nu putea salva emiterea, pentru că emiterea depindea de ANAF.

A doua aplicație le-a separat. Emiterea e o operație locală: validare, numerotare, salvare în baza de date proprie. Transmiterea e o operație separată, asincronă, cu propriul ciclu de viață. Pentru utilizator, factura trece prin stări vizibile:

  1. Emisă — salvată local, cu număr alocat. Operația sincronă se oprește aici.
  2. În așteptare transmitere — un mesaj în Outbox, scris în aceeași tranzacție cu factura.
  3. Transmisă — încărcată în SPV, cu index de încărcare primit.
  4. Validată sau Respinsă — după verificarea ulterioară a stării.

Transmiterea o face un worker din fundal, care folosește toate mecanismele din serie:

public sealed class EFacturaUploadWorker(
    IServiceScopeFactory scopeFactory,
    [FromKeyedServices("anaf")] CircuitBreakerStateProvider circuit,
    WorkerHeartbeat heartbeat,
    ILogger<EFacturaUploadWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            heartbeat.Beat();  // liveness: worker-ul trăiește, chiar dacă ANAF nu

            if (circuit.CircuitState is CircuitState.Open or CircuitState.Isolated)
            {
                continue;  // nu are rost să încercăm, circuitul e deschis
            }

            await using var scope = scopeFactory.CreateAsyncScope();
            var uploader = scope.ServiceProvider.GetRequiredService<EFacturaUploader>();

            try
            {
                await uploader.UploadPendingBatchAsync(stoppingToken);
            }
            catch (BrokenCircuitException)
            {
                logger.LogInformation("ANAF indisponibil, reluăm la următorul ciclu.");
            }
        }
    }
}

CircuitBreakerStateProvider-ul e același obiect pe care l-ai dat circuit breaker-ului din pipeline-ul anaf, înregistrat în container ca serviciu keyed: builder.Services.AddKeyedSingleton("anaf", anafCircuit).

Recunoști piesele: heartbeat-ul din articolul despre health checks, care menține liveness-ul corect cât timp ANAF e jos, starea circuit breaker-ului din articolul 3, care oprește apelurile inutile, și Outbox-ul din articolul despre retry, care garantează că nicio factură nu se pierde.

Decuplarea nu elimină complet riscul, ci îl mută într-un loc controlabil. Transmiterea are un termen legal, iar o factură care stă prea mult în așteptare devine o problemă reală. Așa că adaugi o alertă: dacă există facturi netransmise mai vechi de, să zicem, 24 de ore, cineva trebuie să afle. Degradarea controlată nu înseamnă că ignori problema. Înseamnă că o gestionezi fără ca utilizatorul să fie blocat de ea.

Strategia 4: hedging pentru latență

Până acum am tratat dependențe care nu răspund. Există și varianta mai subtilă: dependențe care răspund de obicei repede, dar uneori, imprevizibil, foarte încet. Un p50 de 50 ms, dar un p99 de 3 secunde. Retry-ul nu ajută, pentru că cererea nu eșuează, doar întârzie.

Hedging-ul rezolvă asta: dacă prima cerere nu a răspuns într-un interval scurt, trimiți încă una în paralel, fără s-o anulezi pe prima. Folosești răspunsul care vine primul și o anulezi pe cealaltă.

pipeline.AddHedging(new HedgingStrategyOptions<HttpResponseMessage>
{
    MaxHedgedAttempts = 1,
    Delay = TimeSpan.FromMilliseconds(300)  // aproximativ p95 normal
});

Pentru HttpClient există și AddStandardHedgingHandler(), care poate trimite cererea paralelă către un alt endpoint, de exemplu o altă regiune. Așa obții și toleranță la căderea unei regiuni întregi.

Hedging-ul are două condiții stricte. Prima: operația trebuie să fie idempotentă, pentru că o vei executa, cel puțin parțial, de două ori. Practic, hedging-ul e pentru citiri, nu pentru scrieri. A doua: costă trafic suplimentar. Cu Delay setat la p95, aproximativ 5% din cereri generează o a doua cerere. Dacă îl setezi prea mic, dublezi încărcarea dependenței.

Strategia 5: feature flags ca întrerupătoare de avarie

Toate strategiile de mai sus sunt automate. Uneori ai nevoie și de un buton manual: o funcționalitate care consumă prea multe resurse în timpul unui incident, o integrare cu un furnizor care are probleme cunoscute, un raport greu pe care vrei să-l oprești la Black Friday.

builder.Services.AddFeatureManagement();
{
  "FeatureManagement": {
    "Recomandari": true,
    "ExportPdfAvansat": true,
    "DashboardStatisticiLive": true
  }
}
public async Task<PaginaCheckout> GetCheckoutAsync(int cosId, CancellationToken ct)
{
    var pagina = await _checkout.BuildAsync(cosId, ct);

    if (await _featureManager.IsEnabledAsync("Recomandari"))
    {
        pagina.Recomandari = await _recomandari.GetAsync(cosId, ct);
    }

    return pagina;
}

Combinat cu o sursă de configurare dinamică, cum ar fi Azure App Configuration cu refresh automat, poți opri o funcționalitate în câteva secunde, fără deploy. Aceste flag-uri se numesc kill switches. Diferența față de circuit breaker e că acolo decide algoritmul, iar aici decide un om, care știe ceva ce algoritmul nu știe: că furnizorul a anunțat o problemă, că urmează un vârf de trafic sau că o funcționalitate trebuie sacrificată pentru ca restul să supraviețuiască.

Comunică degradarea, nu o ascunde

Diferența dintre cele două aplicații de facturare nu a fost doar tehnică. A doua i-a spus utilizatorului ce se întâmplă. Un sistem degradat care se comportă ca unul sănătos e uneori mai periculos decât unul care afișează o eroare.

  • În interfață: badge-uri de stare pe entități („în așteptare transmitere”), bannere globale pentru funcționalitățile afectate, date marcate cu momentul ultimei actualizări.
  • În API: câmpuri explicite în răspuns (isStale, asOf), astfel încât și clienții API să poată lua decizii informate.
  • Pentru funcționalitățile critice care chiar nu pot funcționa: 503 Service Unavailable cu Retry-After și un ProblemDetails clar, nu un 500 generic.
  • Pentru echipă: fiecare fallback executat e logat și numărat. OnFallback din Polly e locul potrivit. Un fallback silențios care rulează de trei zile fără ca cineva să știe e un incident ascuns, nu o degradare controlată.
OnFallback = args =>
{
    logger.LogWarning(args.Outcome.Exception,
        "Fallback activat pentru cursul BNR: servim ultima valoare cunoscută.");
    return default;
}

Recapitulare

  • Graceful degradation e o decizie de produs înainte de a fi una tehnică. Clasifică funcționalitățile în critice, importante și opționale.
  • Fallback-ul stă în exteriorul pipeline-ului: intervine doar după ce retry-ul și circuit breaker-ul au eșuat.
  • Last known good: servește date mai vechi, marcate explicit, cu o limită de vechime.
  • Un fallback poate oferi date mai vechi sau mai puține, niciodată false. Când singura alternativă e o valoare inventată, eroarea e alegerea corectă.
  • Decuplarea asincronă (Outbox + worker) transformă o dependență blocantă într-o întârziere gestionabilă.
  • Hedging pentru latența imprevizibilă, doar pe operații idempotente.
  • Feature flags ca întrerupătoare manuale pentru incidente.
  • Comunică degradarea în interfață, în API și în monitorizare.

Seria Reziliență în .NET

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

Am construit în cinci articole un întreg sistem de apărare: timeout-uri, retry-uri, circuit breaker-e, bulkhead-uri, health checks și fallback-uri. Rămâne o singură întrebare incomodă: de unde știi că funcționează? Răspunsul, în ultimul articol din serie: chaos engineering, cu strategiile de injectare a erorilor integrate în Polly v8 și teste care dovedesc că degradarea controlată chiar se întâmplă atunci când trebuie.