automation

Retry strategy: exponential backoff, jitter și când să dai up

Retry strategy decide când reîncerci un apel eșuat și cu ce întârziere. Exponential backoff, jitter, parametri optimi și anti-patternuri costisitoare.

Cuprins

Retry strategy este setul de reguli care decide, după un apel eșuat, dacă trebuie reîncercat, de câte ori și cu ce întârziere. Fără o strategie explicită, o eroare tranzitorie de rețea sau un rate limit temporar întoarce o eroare evitabilă la utilizator; o strategie greșită propagă o avalanșă de cereri exact când un downstream este deja suprasolicitat.

Exponential backoff cu jitter a devenit standardul de facto pentru integrările cu API-uri externe, popularizat de AWS în 2015 pentru SDK-urile proprii. Confuzia frecventă este că orice eroare merită retry. Nu merită. Strategie de retry fără distincție între erori tranzitorii și permanente este mai periculoasă decât lipsa strategiei.

De ce ai nevoie de retry-uri și cum diferă erorile tranzitorii de cele permanente?

Erorile tranzitorii sunt temporare prin natură: timeout de rețea, supraîncărcare momentană a serverului (503 Service Unavailable), depășire de rate limit (429 Too Many Requests). Downstream-ul este disponibil și va răspunde corect dacă reîncerci după o pauză. Erorile permanente, în schimb, nu dispar prin reîncercare: 400 Bad Request (cererea ta este malformată), 401 Unauthorized (credențialele sunt expirate sau greșite), 404 Not Found (resursa nu există). Un retry pe 401 fără să reîmprospătezi tokenul este un loop infinit, nu o strategie.

Clasa de erori care candidează în mod sigur la retry:

  • Timeout de rețea. Cererea nu a ajuns sau răspunsul nu s-a întors în fereastra de timp alocată. Cauza poate fi un spike de trafic, un restart al downstream-ului sau un incident tranzitoriu de rețea.
  • 503 Service Unavailable. Serverul este temporar incapabil să proceseze cererea. Un server în startup, în deploy sau sub presiune poate returna 503 câteva secunde.
  • 429 Too Many Requests. Ai depășit limita de rată. Ideally, răspunsul conține un header Retry-After care spune exact cât trebuie să aștepți. Dacă nu, aplici backoff.

Clasa de erori care nu candidează la retry: 400, 401, 403 (în afara cazurilor cu semantică de rate limit documentată), 404, 405, orice eroare de validare. Dacă reîncerci un 400, cererea va eșua identic de n ori. Ai pierdut timp și ai pus presiune pe downstream fără niciun beneficiu.

Care sunt strategiile principale de retry?

Cele patru strategii de bază, de la cea mai simplă la cea mai robustă:

  • Fixed delay. Aștepți același interval între fiecare încercare (exemplu: 5 secunde între orice retry). Simplu de implementat și de înțeles. Dezavantajul este sincronizarea: dacă zece clienți eșuează simultan, toți reîncep în același moment, creând un val sincronizat care poate suprasolicita din nou downstream-ul.
  • Linear backoff. Întârzierea crește liniar cu numărul de încercări: 1s, 2s, 3s, 4s. Ușor mai rărit decât fixed, dar tot susceptibil la sincronizare cross-client.
  • Exponential backoff. Întârzierea se dublează la fiecare încercare: delay × 2^attempt. Cu o întârziere inițială de 100ms și trei retry-uri: 100ms, 200ms, 400ms. Creste rapid, deci trebuie temperat cu un cap (limita superioară). Fără cap, al zecelea retry ar astepta peste 100 de secunde. Practică obișnuită: cap la 30s-1min.
  • Exponential backoff cu jitter. La intervalul exponențial se adaugă variație aleatorie. Full jitter (varianta AWS): random(0, base × 2^n). Decorrelated jitter: random(base, prev_delay × 3). Jitter-ul sparge sincronizarea: chiar dacă toți clienții eșuează în același moment, reîncep la momente diferite. Esențial când ai zeci sau sute de clienți paraleli care apelează același downstream.

Alegerea depinde de context. Un singur caller cu un singur retry pe un API cu semnal explicit nu câștigă nimic din jitter. Zeci de instanțe paralele care apelează același downstream au nevoie de jitter ca să evite thundering herd: valul sincronizat de cereri care lovește serverul exact când încearcă să se recupereze.

Cum alegi parametrii: initial delay, max attempts, cap, jitter type?

Nu există valori universale, dar există intervale de start rezonabile care se ajustează pe baza comportamentului observat:

  • Initial delay. Între 100ms și 1s. Mai mic pentru servicii interne cu latență scăzută, mai mare pentru API-uri externe. Un initial delay de 50ms pe un API care procesează cereri în 200ms nu dă downstream-ului nicio șansă să se recupereze.
  • Max attempts. Între 3 și 5 pentru majoritatea cazurilor. Cu 3 retry-uri și exponential backoff 1s initial, cap 30s, poți sta blocat până la ~35 de secunde pe o cerere care eșuează complet. Fiecare attempt adaugă latență percepută de utilizator sau de procesul care apelează.
  • Cap (max delay). Între 30s și 1min pentru API-uri externe. Fără cap, backoff-ul exponențial crește la valori impractice după câteva attempt-uri.
  • Jitter type. Full jitter este suficient pentru marea majoritate. Decorrelated jitter poate distribui mai bine la volume mari, dar implementarea adaugă stare (trebuie să ții minte delay-ul anterior). Dacă nu ai zeci de clienți paraleli, nu justifică complexitatea.

Indiferent de parametri, pune un timeout global pe întreaga operație, nu doar pe fiecare apel individual. Cu 5 attempt-uri și cap de 30 de secunde, o operație poate dura peste 2 minute fără o limită externă. Orice cron job care apelează un API extern are nevoie de un timeout global tratat ca eroare definitivă și raportat în stack-ul de observabilitate.

Cum o folosim noi în SmartBillClient: fixed delay, un singur retry, semnal explicit de rate limit?

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ă, fără headere de tip Retry-After. Clientul nostru tratează acel 403 ca semnal de rate limit, doarme DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000L (60 de secunde), reîncercă o singură dată, și dacă al doilea apel eșuează tot, propagă eroarea. Alte coduri 4xx și 5xx sunt propagate imediat, fără retry1.

Aceasta este o strategie minimă: fixed delay, maxim 1 retry, declanșată doar pe un cod specific. Este potrivită pentru un API care semnalizează explicit temporizarea și pentru volumul nostru de operații pe SmartBill. Nu este suficientă pentru integrări cu trafic mai mare sau pentru API-uri care nu disting erorile temporare de cele permanente. Pentru acelea, exponential backoff cu jitter este standardul, implementat fie direct (Spring Retry, Resilience4j, Polly), fie printr-o coadă de mesaje cu politică de reîncercare la nivel de broker. Un retry cu fixed delay de 60 de secunde pe un apel nu este un circuit breaker: breaker-ul contorizează eșecurile pe o fereastră și taie traficul sistematic.

Care e anti-pattern-ul cel mai costisitor?

Trei anti-patternuri care apar frecvent în implementări reale și care costă mai mult decât lipsa retry-ului:

  • Retry pe erori non-retriable. Un 400 Bad Request sau un 401 Unauthorized nu se rezolvă prin reîncercare: cauza nu este tranzitorie. Un retry pe 401 fără logică de refresh devine un loop infinit care epuizează rate limit-ul și consumă fire de execuție, cu un eveniment în runbook ca rezultat.
  • Thundering herd fără jitter. Când zeci de clienți eșuează simultan și reîncep toți cu același fixed delay, lovesc serverul în recovery în val sincronizat: rata de eșec rămâne ridicată și fiecare val îl declanșează pe următorul. Jitter-ul sparge sincronizarea la un cost de implementare neglijabil.
  • Retry pe operații non-idempotente fără idempotency key. Un POST care creează o factură sau o comandă fără mecanism de deduplicare va crea duplicate la retry. Dubla facturare este scenariul care nu iese în staging decât prin injecție de erori deliberată; un SLA cu clauze de integritate a datelor devine imposibil de menținut fără idempotency pe scrieri.
  1. SmartBill returnează 403 pentru rate limit (nu 429) și nu expune headere Retry-After. SmartBillClient.java din crawlerra-backend folosește un backoff fix de 60 de secunde și un singur retry pe acel cod. Alte coduri de eroare sunt propagate direct, fără retry. Aceasta nu este exponential backoff, ci o strategie minimă potrivită pentru un API cu semnal de rate limit explicit. [crawlerra.smartbill_backoff]

Întrebări frecvente

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

Retry strategy decide dacă și cum reîncerci un apel eșuat; circuit breaker-ul decide dacă mai are sens să încerci deloc. Cele două se combină: retry-ul rezolvă erorile tranzitorii izolate, circuit breaker-ul oprește apelurile când downstream-ul este sistematic în jos. Retry fără circuit breaker poate amplifica o problemă; circuit breaker fără retry pierde oportunitatea de a recupera din erori temporare.

Exponential backoff este întotdeauna mai bun decât fixed delay?

Nu, depinde de natura API-ului. Exponential backoff este standard pentru API-uri cu comportament impredictibil (503, timeout de rețea). Fixed delay este mai simplu și suficient când serverul semnalizează explicit cât trebuie să aștepți (ca un 403 cu semantică de rate limit pentru care știi că 60 de secunde e suficient). Complexitatea în plus din exponential backoff nu aduce valoare dacă nu ai mai mult de un singur retry.

Ce este jitter și de ce contează?

Jitter este variația aleatorie adăugată întârzierii de retry ca să previi thundering herd. Fără jitter, toți clienții care au eșuat simultan reîncep la același moment, suprasolicitând din nou downstream-ul. Cu full jitter (AWS), întârzierea este random(0, cap × 2^n) în loc de cap × 2^n fix. Diferența e vizibilă la zeci de clienți paraleli, neglijabilă dacă ai un singur caller.

Retry pe un POST este întotdeauna periculos?

Da, dacă nu ai idempotency key sau nu verifici că resursa nu există deja. Un POST care creează o comandă, o factură sau un transfer fără idempotency key va crea duplicate la retry. Dacă operația nu este idempotentă prin design, trebuie să fie idempotentă prin implementare: idempotency key în header, verificare în DB înainte de creare, sau mutare în coadă cu exactly-once semantics.

Cât de mare poate fi max_attempts?

Între 3 și 5 pentru majoritatea cazurilor; niciodată mai mult de 10 fără circuit breaker pe deasupra. Fiecare retry adaugă latență percepută și presiune pe downstream. Cu 5 retry-uri și exponential backoff cu cap de 30 de secunde, un apel care eșuează complet poate dura câteva minute. Pune un timeout global pe întreaga operație, nu doar pe fiecare apel individual.