automation

Circuit breaker: când deschizi circuitul și cum revii

Circuit breaker protejează un serviciu de cascada de eșecuri când un downstream nu mai răspunde. Cele trei stări, cum alegi pragurile și anti-pattern-ul clasic.

Cuprins

Circuit breaker este un pattern de reziliență care protejează un serviciu de cascada de eșecuri cauzată de un downstream lent sau indisponibil. Când numărul de eșecuri depășește un prag configurat, breaker-ul deschide circuitul: apelurile ulterioare sunt respinse imediat, fără să mai atingă downstream-ul, până când acesta are timp să se recupereze.

Termenul a fost popularizat de Michael Nygard în Release It! (2007) ca răspuns la un fenomen din sistemele distribuite: un serviciu downstream lent nu pică aplicația clientului direct, ci o sufocă gradual prin acumularea de fire de execuție blocate în așteptarea timeout-ului. Confuzia frecventă este că ar fi un înlocuitor pentru idempotency sau pentru rate limiting; nu este. Fiecare rezolvă o clasă diferită de probleme.

Ce este un circuit breaker mai exact?

Un mecanism de protecție interpus între un client și un downstream, cu o mașină de stări cu trei poziții. Metafora vine din electronică: un disjunctor rupe circuitul când curentul depășește un prag periculos, protejând instalația.

În software, downstream-ul poate fi orice: un API extern, o bază de date, un serviciu intern, un provider de email. Circuit breaker-ul înregistrează rezultatele apelurilor și, dacă rata de eșecuri depășește pragul, trece în modul deschis: respinge cererile noi imediat, fără să le mai trimită downstream. După o perioadă de așteptare, lasă să treacă un subset mic de cereri de test. Dacă reușesc, circuitul se închide. Dacă eșuează, revine la deschis.

  • Protecție pentru client: un apel respins imediat eliberează firul de execuție în microsecunde, în loc să-l blocheze zeci de secunde în timeout.
  • Protecție pentru downstream: un serviciu în recuperare nu primește trafic suplimentar cât timp nu este pregătit.
  • Vizibilitate: un breaker deschis este un semnal explicit de incident, vizibil în stack-ul de observabilitate, nu doar o eroare pierdută în log-uri.

Care sunt cele trei stări (closed, open, half-open) și ce le declanșează?

Mașina de stări a unui circuit breaker are trei poziții:

  • Closed (normal). Apelurile trec. Breaker-ul contorizează eșecurile într-o fereastră de timp sau de număr de cereri. Câtă vreme rata de eșecuri rămâne sub prag, nu se întâmplă nimic. Aceasta este starea implicită la pornire.
  • Open (circuit tăiat). Rata de eșecuri a depășit pragul. Orice apel nou este respins imediat cu o excepție sau un răspuns de rezervă, fără să ajungă la downstream. Breaker-ul pornește un cronometru (fereastra de recuperare).
  • Half-open (testare). Fereastra de recuperare a expirat. Breaker-ul permite un număr mic de cereri de test. Dacă reușesc, trece în closed. Dacă eșuează, revine în open.

Tranzițiile sunt asimetrice: din closed în open se face rapid, dar revenirea trece obligatoriu prin half-open. Un downstream recuperat parțial care primește brusc traficul complet poate recădea imediat.

Cum alegi pragul de declanșare (failure rate, time window, sample size)?

Configurația unui circuit breaker are trei parametri principali:

  • Failure rate threshold. Procentul de eșecuri din fereastră care declanșează open. Un prag de 50% este un punct de plecare obișnuit pentru servicii externe; 30–40% pentru servicii interne critice. Prea conservator, produce false pozitive la turbulențe tranzitorii.
  • Time window / sliding window size. Fereastra în care se contorizează eșecurile: bazată pe timp (ultimele 10 secunde) sau pe număr de cereri (ultimele 100 de apeluri). Fereastra prea mică deschide breaker-ul la orice mic spike; prea mare, eșecurile se diluează și breaker-ul nu reacționează la timp.
  • Minimum number of calls. Numărul minim de apeluri necesar înainte ca breaker-ul să evalueze rata. Fără acest parametru, două apeluri eșuate din două dau 100% rată de eșec și deschid circuitul înainte ca orice date semnificative să existe. Un minimum de 10–20 de cereri filtrează zgomotul.

Valorile potrivite depind de latența normală a downstream-ului și de volumul de trafic. Reglajul bun pornește de la valorile implicite ale bibliotecii, observă comportamentul real câteva săptămâni, și ajustează pe baza datelor. Orice tranziție de stare merită un eveniment de log structurat, ca să poți corela incidentele cu comportamentul breaker-ului în practica de site reliability.

Ce pattern adiacent folosim noi la SmartBill, fără să fie strict circuit breaker?

Un caz concret din producție: SmartBillClient.java din crawlerra-backend integrează API-ul de facturare SmartBill, care aplică un rate limit strict și returnează 403 (nu 429) când limita este depășită. La un 403, clientul nostru doarme DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000L (60 de secunde), reîncearcă o singură dată, și dacă al doilea apel eșuează tot, propagă eroarea1.

Acesta nu este un circuit breaker. Diferența este importantă: un circuit breaker complet ar contoriza eșecurile pe o fereastră, ar trece în starea open după N eșecuri, ar ține traficul departe pentru o perioadă variabilă, și ar testa recuperarea în half-open. Noi facem un singur retry cu backoff fix de 60 de secunde. Motivul este că SmartBill se comportă ca un circuit breaker server-side (returnează 403 explicit ca semnal de temporizare), deci clientul nu trebuie să detecteze eșecul sistematic, ci să respecte temporizarea impusă. Pentru o integrare cu trafic mai mare sau cu un API care nu semnalizează explicit, un breaker real (Resilience4j, Polly) ar fi necesar, nu un retry cu backoff fix.

Care e anti-pattern-ul cel mai costisitor?

Combinarea circuit breaker-ului cu retry-uri necontrolate în același strat. Scenariul tipic: un middleware de retry repetă orice apel eșuat de trei ori, și deasupra lui un circuit breaker evaluează rata de eșecuri. Când downstream-ul cade, fiecare cerere originală generează trei cereri reale, triplând traficul exact când downstream-ul are cel mai mult nevoie să fie lăsat să se recupereze. Breaker-ul se declanșează mai târziu decât ar trebui (retry-urile reușite diluează rata de eșec) și a consumat deja resurse triple.

  • Retry fără jitter. Toate clientele reîncearcă la același interval fix, creând un val sincronizat. Adaugă variație aleatorie la intervalul de retry ca să distribui traficul în timp.
  • Timeout mai lung decât fereastra breaker-ului. Dacă timeout-ul este de 30 de secunde dar fereastra de evaluare este de 10 secunde, firele de execuție blocate în timeout continuă să consume resurse după ce breaker-ul a trecut deja în open.
  • Fallback care ascunde problema. O valoare implicită goală sau veche face aplicația să pară că funcționează corect, ascunzând incidentul față de utilizator și față de monitorizare. Fallback-ul trebuie să fie un răspuns degradat explicit, nu un succes fals. Un runbook pentru starea de circuit deschis trebuie să descrie ce face sistemul în acea fereastră și cum se verifică revenirea.
  • Un singur breaker pentru toate endpoint-urile unui downstream. Dacă un endpoint devine lent, un breaker global deschide circuitul pentru toate. Granularitatea corectă este un breaker per resursă sau per tip de operație, nu per host.
  • Circuit breaker fără observabilitate. Un breaker care tranzitează stările fără să emită metrici sau log-uri este invizibil. Nu știi când s-a declanșat, cât a stat deschis, câte cereri a respins. Tranzițiile de stare trebuie tratate ca evenimente de primă clasă: log structurat la fiecare tranziție, alertă dacă breaker-ul stă deschis mai mult de N minute. Verificarea că un SLA rămâne intact în prezența unui downstream degradat presupune că știi când și cât timp a stat breaker-ul deschis.

Combinarea corectă: retry-urile rulează în interiorul breaker-ului, nu deasupra lui. Breaker-ul vede rezultatul final al secvenței de retry, nu fiecare încercare individuală. Dacă după trei retry-uri cu jitter exponențial cererea tot eșuează, aceea este eșuarea pe care breaker-ul o contorizează. Injecția de erori controlată în staging este singurul mod de a verifica că secvența se comportă cum ai proiectat.

  1. SmartBill returnează 403 pentru rate limit (nu 429) și nu expune headere de tip Retry-After. SmartBillClient.java din crawlerra-backend folosește un backoff fix de 60 de secunde și un singur retry. Acesta nu este un circuit breaker cu stări closed/open/half-open, ci un retry cu backoff fix ca răspuns la un semnal explicit de rate limiting din partea serverului. [crawlerra.smartbill_backoff]

Întrebări frecvente

Ce face un circuit breaker în practică?

Oprește apelurile către un serviciu care eșuează, returnând imediat un răspuns de eroare, fără să mai aștepte timeout-ul. Când downstream-ul este suprasolicitat, fiecare apel care mai ajunge la el înrăutățește situația. Circuit breaker-ul taie legătura rapid, protejând și clientul (nu mai blochează fire de execuție), și serverul downstream (nu mai primește trafic cât timp se recuperează).

Care e diferența dintre circuit breaker și retry?

Retry repetă un apel eșuat; circuit breaker decide dacă mai are sens să încerci. Retry-ul rezolvă erorile tranzitorii izolate. Circuit breaker-ul rezolvă eșecurile sistematice: când un serviciu este în jos zece minute, retry-ul fără breaker generează mii de cereri blocate; breaker-ul oprește traficul din primul minut. Cele două pattern-uri se combină: retry în starea half-open este mecanismul prin care breaker-ul testează recuperarea.

Ce fac în starea open când nu am valoare implicită (fallback)?

Returnezi o eroare explicită rapid, fără să blochezi. Nu orice sistem poate oferi un fallback util: dacă un API de plăți este down, nu există răspuns de rezervă valabil. Circuit breaker-ul tot are sens: în loc să blochezi 30 de secunde per cerere în timeout, eșuezi imediat cu un mesaj clar, eliberând resursele serverului tău.

Resilience4j, Polly, Hystrix, care aleg?

Resilience4j pentru Java/Kotlin, Polly pentru .NET; Hystrix este în mentenanță și nu se recomandă pentru proiecte noi. Resilience4j este lightweight, fără dependențe externe, și se integrează nativ cu Spring Boot Actuator pentru metrici. Dacă nu vrei o dependență de bibliotecă, poți implementa un breaker simplu in-memory în 100-150 de linii, cu un AtomicReference pentru stare și un timer pentru fereastra de timp.

Cum știu că breaker-ul meu funcționează corect?

Provoci eșecul downstream-ului într-un mediu de testare și verifici că breaker-ul trece în open în fereastra de timp așteptată, că respinge cererile ulterior, și că trece în half-open după expirarea perioadei de recuperare. Fără un test explicit de injecție de erori, nu poți ști dacă parametrii sunt calibrați corect sau dacă breaker-ul a emis vreodată o tranziție. Este exact cazul de uz pentru chaos engineering.