RO EN

Reziliență în .NET (6/6): chaos engineering, sau cum dovedești că reziliența funcționează

Reziliență în .NET (6/6): chaos engineering, sau cum dovedești că reziliența funcționează ✨ Imagine generată cu AI
Doru Bulubașa
14 octombrie 2026
24 vizualizări

Ai urmărit seria. Ai pus timeout-uri pe fiecare apel extern, retry cu jitter, circuit breaker-e per dependență, bulkhead-uri, health checks separate și fallback-uri care servesc ultima valoare bună cunoscută. Codul arată impecabil în code review.

Acum o întrebare incomodă: ai văzut vreodată circuit breaker-ul deschizându-se? Nu în documentație, nu în test-ul unitar al bibliotecii, ci în aplicația ta, cu configurația ta, sub trafic real.

Dacă răspunsul e nu, ai ceva ce arată ca reziliența, dar nu știi dacă e reziliență. Poate predicatul ShouldHandle nu prinde excepția pe care o aruncă de fapt clientul tău. Poate MinimumThroughput e atât de mare încât circuitul nu se va deschide niciodată la traficul tău. Poate fallback-ul citește din cache o cheie care nu e scrisă nicăieri. Toate acestea trec de code review. Toate le descoperi abia în timpul incidentului, adică exact în cel mai prost moment.

Chaos engineering e disciplina prin care descoperi aceste probleme înainte de incident, provocând intenționat, controlat, eșecurile împotriva cărora te-ai protejat. E ultimul articol din serie și, într-un fel, cel care le validează pe toate celelalte.

Ce este, de fapt, chaos engineering

Termenul a devenit cunoscut prin Netflix, care în 2011 a lansat Chaos Monkey: un program care oprea aleatoriu servere de producție în timpul programului de lucru. Ideea părea nebunească, dar logica era impecabilă. Serverele oricum vor cădea. Mai bine să cadă marți la 11, când toată echipa e la birou, decât duminică la 3 noaptea.

De la Chaos Monkey, disciplina s-a maturizat. Nu mai înseamnă „stricăm lucruri la întâmplare și vedem ce se întâmplă”, ci experimente științifice despre comportamentul sistemului. Principiile, formulate în manifestul Principles of Chaos Engineering, sunt patru:

  1. Definește starea stabilă (steady state) prin metrici observabile: rata de succes a cererilor, latența p95, numărul de facturi emise pe minut. Nu „merge”, ci cifre.
  2. Formulează o ipoteză: starea stabilă se menține și în condițiile evenimentului pe care îl provoci.
  3. Introdu evenimente din lumea reală: latență, erori, dependențe căzute, poduri oprite. Nu scenarii exotice, ci exact ce ți s-a întâmplat sau se poate întâmpla.
  4. Minimizează aria de impact (blast radius): începi mic, cu un procent mic de trafic, un singur mediu, o singură dependență, și ai mereu un buton de oprire.

Un experiment bine formulat arată așa:

Ipoteză: dacă 50% din apelurile către ANAF eșuează cu 503 timp de 10 minute, emiterea facturilor rămâne sub 300 ms la p95, nicio factură nu se pierde, iar toate facturile emise în acest interval sunt transmise în maximum 5 minute după revenirea serviciului.

Observă că ipoteza nu spune nimic despre Polly, retry sau circuit breaker. Spune ce simte utilizatorul și ce se întâmplă cu datele. Mecanismele sunt detalii de implementare. Experimentul verifică rezultatul.

Chaos în Polly v8

Pentru aplicațiile .NET, cel mai la îndemână punct de pornire este în pipeline-urile pe care le-ai construit deja. Polly v8 a integrat proiectul Simmy, dedicat chaos engineering-ului, și oferă patru strategii de injectare:

  • Fault (AddChaosFault) — aruncă o excepție, de exemplu un HttpRequestException care simulează o conexiune resetată.
  • Latency (AddChaosLatency) — adaugă întârziere înainte de execuție. Cel mai valoros tip de experiment, pentru că, așa cum am văzut în primul articol, latența e mai periculoasă decât erorile.
  • Outcome (AddChaosOutcome) — returnează un rezultat fals, de exemplu un 503 Service Unavailable sau un 429 Too Many Requests, fără ca cererea să mai plece pe rețea.
  • Behavior (AddChaosBehavior) — execută o acțiune arbitrară, de exemplu golirea unei chei din cache sau închiderea forțată a unei conexiuni.

Fiecare strategie are o rată de injectare (InjectionRate, între 0 și 1) și un comutator (Enabled). Ambele pot fi calculate dinamic la fiecare execuție, ceea ce e esențial pentru controlul experimentului.

Unde pui strategiile de chaos

Strategiile de chaos stau în interiorul pipeline-ului, cât mai aproape de apelul real. Vrei ca eroarea injectată să fie tratată exact ca o eroare reală: prinsă de timeout, reîncercată de retry, numărată de circuit breaker, transformată de fallback. Dacă ai pune chaos-ul în exterior, ai testa doar că o excepție ajunge la apelant, adică nimic util.

Pentru HttpClient, asta înseamnă un handler separat, adăugat după handler-ul standard de reziliență, deci mai aproape de rețea:

public sealed class ChaosOptions
{
    public bool Enabled { get; set; }
    public double InjectionRate { get; set; } = 0.05;
}
builder.Services.Configure<ChaosOptions>(builder.Configuration.GetSection("Chaos"));

var anaf = builder.Services.AddHttpClient<IAnafClient, AnafClient>();
anaf.AddStandardResilienceHandler();

if (!builder.Environment.IsProduction())
{
    anaf.AddResilienceHandler("chaos", (pipeline, context) =>
    {
        var chaos = context.ServiceProvider.GetRequiredService<IOptionsMonitor<ChaosOptions>>();

        ValueTask<bool> IsEnabled(EnabledGeneratorArguments _) =>
            ValueTask.FromResult(chaos.CurrentValue.Enabled);

        ValueTask<double> Rate(InjectionRateGeneratorArguments _) =>
            ValueTask.FromResult(chaos.CurrentValue.InjectionRate);

        pipeline
            .AddChaosLatency(new ChaosLatencyStrategyOptions
            {
                EnabledGenerator = IsEnabled,
                InjectionRateGenerator = Rate,
                Latency = TimeSpan.FromSeconds(5)
            })
            .AddChaosFault(new ChaosFaultStrategyOptions
            {
                EnabledGenerator = IsEnabled,
                InjectionRateGenerator = Rate,
                FaultGenerator = new FaultGenerator()
                    .AddException(() => new HttpRequestException("Chaos: conexiune resetată"))
            })
            .AddChaosOutcome(new ChaosOutcomeStrategyOptions<HttpResponseMessage>
            {
                EnabledGenerator = IsEnabled,
                InjectionRateGenerator = Rate,
                OutcomeGenerator = new OutcomeGenerator<HttpResponseMessage>()
                    .AddResult(() => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable))
            });
    });
}
{
  "Chaos": {
    "Enabled": false,
    "InjectionRate": 0.05
  }
}

Trei straturi de siguranță în câteva linii:

  • Handler-ul nu există în producție. Condiția pe mediu e o plasă de siguranță simplă, dar eficientă. Când echipa e pregătită pentru experimente în producție, o scoți conștient, nu din greșeală.
  • Chaos-ul e oprit implicit. Enabled: false în configurație. Cineva trebuie să-l pornească explicit.
  • Controlul e dinamic. Datorită IOptionsMonitor, schimbarea configurației, de exemplu prin Azure App Configuration cu refresh, pornește sau oprește experimentul în câteva secunde, fără restart. Ăsta e butonul de oprire de care are nevoie orice experiment.

Cu o rată de 5% pentru fiecare dintre cele trei strategii, aproximativ 15% din apeluri sunt afectate. Pentru un prim experiment pe un mediu de test e un punct de plecare bun: suficient cât să vezi retry-urile lucrând, prea puțin ca să deschidă circuitul. Ca să vezi circuitul deschizându-se, crești rata, într-un experiment separat.

Chaos în teste automate: dovezi, nu speranțe

Experimentele pe un mediu de staging sunt valoroase, dar rare. Testele automate rulează la fiecare commit. Combinația celor două e cea mai puternică: aceleași strategii de chaos, cu rată de injectare 1.0, devin deterministe și transformă comportamentul de reziliență în ceva ce poți verifica în CI.

Primul test verifică decuplarea din articolul despre graceful degradation: emiterea unei facturi nu trebuie să depindă de ANAF.

public sealed class RezilientaFacturareTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private WebApplicationFactory<Program> CuAnafCazut() =>
        factory.WithWebHostBuilder(host => host.ConfigureTestServices(services =>
            services.Configure<ChaosOptions>(options =>
            {
                options.Enabled = true;
                options.InjectionRate = 1.0;  // fiecare apel extern eșuează
            })));

    [Fact]
    public async Task Factura_este_emisa_rapid_chiar_daca_ANAF_nu_raspunde()
    {
        var client = CuAnafCazut().CreateClient();

        var stopwatch = Stopwatch.StartNew();
        var response = await client.PostAsJsonAsync("/api/facturi", FacturaDeTest());
        stopwatch.Stop();

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);

        var factura = await response.Content.ReadFromJsonAsync<FacturaDto>();
        Assert.Equal("InAsteptareTransmitere", factura!.StareTransmitere);
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1),
            $"Emiterea a durat {stopwatch.Elapsed.TotalMilliseconds:N0} ms");
    }
}

Testul pare banal, pentru că, în designul din articolul 5, emiterea nici nu apelează ANAF. Exact de aceea e valoros. Peste șase luni, cineva va adăuga o „mică validare” sincronă către SPV în fluxul de emitere. Codul va arăta nevinovat în review, dar testul va pica. Ai transformat o decizie de arhitectură într-o regulă verificată automat.

Al doilea test verifică fallback-ul pentru cursul BNR:

[Fact]
public async Task Cursul_vechi_este_servit_cand_BNR_nu_raspunde()
{
    var app = CuAnafCazut();  // chaos-ul afectează toți clienții HTTP configurați
    var store = app.Services.GetRequiredService<LastKnownGoodStore>();

    await store.SetAsync("curs-eur",
        new CursValutar("EUR", 4.9750m, new DateOnly(2026, 10, 9)), CancellationToken.None);

    var client = app.CreateClient();
    var curs = await client.GetFromJsonAsync<CursValutar>("/api/curs/eur");

    Assert.NotNull(curs);
    Assert.True(curs.EsteVechi);
    Assert.Equal(4.9750m, curs.Valoare);
}

Și un al treilea, la fel de important, pe care îl las ca exercițiu: când nu există nicio valoare salvată, endpoint-ul trebuie să returneze o eroare clară, nu un curs inventat. Regula din articolul 5, „un fallback greșit e mai rău decât o eroare”, merită propriul test.

Ce merită acoperit cu teste de chaos

  • Timeout-urile chiar taie. Cu latență injectată de 5 secunde și un timeout de 2, operația trebuie să se termine în aproximativ 2 secunde, nu în 5. Acesta prinde imediat un CancellationToken nepropagat, capcana din primul articol.
  • Circuitul se deschide. După suficiente eșecuri, apelurile următoare se termină în milisecunde. Măsori durata, nu doar rezultatul.
  • Retry-ul nu reîncearcă ce nu trebuie. Un 400 injectat trebuie să ajungă la apelant după o singură încercare.
  • Health checks-urile raportează corect. Cu circuitul deschis, /health/deps spune Degraded, iar /health/live și /health/ready rămân Healthy. E exact separarea din articolul 4.

Dincolo de Polly: chaos la nivel de infrastructură

Strategiile din Polly injectează erori în interiorul aplicației. Sunt precise și ușor de controlat, dar nu acoperă tot. Nu testează ce se întâmplă când un pod dispare brusc, când rețeaua pierde pachete sau când baza de date face failover. Pentru asta ai nevoie de instrumente care lucrează sub aplicație:

  • Toxiproxy — un proxy TCP care introduce latență, pierderi și deconectări între aplicație și o dependență reală. Disponibil și prin Testcontainers, deci îl poți folosi direct în testele de integrare, cu un SQL Server sau un Redis real.
  • Chaos Mesh și LitmusChaos — pentru Kubernetes: opresc poduri, introduc latență în rețea, consumă CPU sau memorie, simulează probleme de DNS.
  • Azure Chaos Studio — serviciul gestionat de la Microsoft pentru experimente pe resurse Azure: oprirea unei mașini virtuale, failover pe Cosmos DB, blocarea traficului într-un NSG.

Cel mai simplu experiment de infrastructură nu cere însă niciun instrument: kubectl delete pod pe unul dintre podurile aplicației, în timpul unui test de încărcare. Verifici trei lucruri: că nicio cerere nu eșuează (oprirea elegantă cu preStop), că podul nou nu primește trafic înainte de warm-up (startup și readiness probe) și că metricile revin la starea stabilă. Zece minute de experiment care validează un întreg articol din serie.

Game day: chaos cu oameni

Unele lucruri nu se pot testa automat: dacă alertele ajung la cine trebuie, dacă echipa înțelege dashboard-urile, dacă runbook-ul e corect, dacă cineva știe unde e butonul de kill switch din articolul 5. Pentru acestea există game day-ul: o sesiune programată în care echipa rulează împreună un experiment și observă cum reacționează atât sistemul, cât și oamenii.

  1. Alege un scenariu realist și formulează ipoteza, cu metricile stării stabile.
  2. Stabilește condițiile de oprire înainte de start: dacă rata de erori pentru utilizatori trece de X%, oprești imediat.
  3. Anunță experimentul tuturor celor care ar putea fi afectați sau alarmați.
  4. Rulează-l, cu cineva care observă metricile și cineva care notează tot ce se întâmplă.
  5. Fă o retrospectivă: ce a mers conform ipotezei, ce nu, ce acțiuni rezultă. Fiecare ipoteză infirmată e un incident de producție pe care l-ai evitat.

De unde începi

Chaos engineering în producție, la Netflix, e capătul unui drum lung. Pentru majoritatea echipelor, ordinea realistă e alta:

  1. Teste automate cu chaos determinist în CI. Cost mic, valoare imediată, niciun risc.
  2. Experimente pe staging, cu rate mici de injectare, în timpul unor teste de încărcare.
  3. Game days trimestriale, cu un scenariu nou de fiecare dată.
  4. Experimente în producție, cu blast radius minim: un tenant intern, un procent infim de trafic, un kill switch testat. Doar când observabilitatea e solidă și echipa are încredere. Pentru multe echipe mici, acest pas poate să nu vină niciodată, și e în regulă.

O condiție preliminară pentru toate: observabilitatea. Fără metricile Polly în OpenTelemetry, fără loguri pe OnRetry, OnOpened și OnFallback, un experiment de chaos e doar stricare de lucruri. Nu poți verifica o ipoteză despre starea stabilă dacă nu poți măsura starea stabilă.

Încheierea seriei

Am pornit acum șase articole de la o aplicație de facturare care se bloca la final de lună din cauza unei dependențe lente. Pe drum am construit, strat cu strat, un sistem de apărare:

  1. Fundamente și Polly v8 — taxonomia eșecurilor, timeout-ul ca primă linie de apărare și pipeline-urile de reziliență.
  2. Retry policies inteligente — backoff cu jitter, idempotență, retry storms și retry budget.
  3. Circuit breaker și bulkhead — oprirea apelurilor către dependențe bolnave și izolarea resurselor.
  4. Health checks avansate — liveness, readiness și startup, fiecare la locul lui.
  5. Graceful degradation — ce oferi utilizatorului când, inevitabil, ceva cade.
  6. Chaos engineering — cum dovedești că toate cele de mai sus chiar funcționează.

Dacă ar fi să comprim seria în câteva idei, ar fi acestea:

  • Eșecul nu e o excepție, e o condiție de funcționare. Întrebarea nu e dacă o dependență va cădea, ci ce face aplicația ta atunci.
  • Un eșec rapid e un dar. Timeout-urile, circuit breaker-ele și bulkhead-urile transformă așteptări imprevizibile în erori rapide și tratabile.
  • Reziliența se proiectează la nivel de sistem. Retry-uri pe toate straturile, dependențe partajate în readiness sau fallback-uri silențioase sunt bune local și dezastruoase global.
  • Degradarea e o decizie de produs. Mecanismele tehnice îți dau timp și opțiuni. Ce faci cu ele decizi împreună cu cine înțelege utilizatorul.
  • Ce n-ai testat, nu știi. O strategie de reziliență pe care n-ai văzut-o niciodată în acțiune e o ipoteză, nu o garanție.

Sistemele distribuite vor continua să cadă în moduri noi și creative. Diferența dintre o echipă care doarme liniștită și una care e trezită la 3 noaptea nu e că sistemele primei echipe nu cad. E că, atunci când cad, cad într-un fel pe care l-au anticipat, l-au proiectat și l-au testat.

Mulțumesc că ai parcurs seria până la capăt. Dacă ai rulat un experiment de chaos după ce ai citit-o și ai descoperit ceva neașteptat, mi-ar plăcea să aflu ce anume.