backend

Idempotency: ce este, idempotency keys și cum eviți retry-urile distructive

Idempotency înseamnă că poți repeta o operație de oricâte ori fără consecințe suplimentare. Cum funcționează idempotency keys, trade-off-urile și capcanele.

Cuprins

Idempotency este proprietatea unei operații de a produce același rezultat indiferent de câte ori o execuți. În contextul API-urilor și al sistemelor distribuite, înseamnă că un client poate repeta un request fără să producă efecte nedorite: duplicate de comenzi, plăți duble, înregistrări create de mai multe ori.

Termenul vine din matematică: o funcție f este idempotentă dacă f(f(x)) = f(x) pentru orice x. Translat în software, un endpoint idempotent se comportă identic dacă îl apelezi o dată sau de zece ori cu aceleași date. Această proprietate este fundamentul pe care se construiesc retry-urile sigure.

Ce înseamnă idempotent mai exact?

Un endpoint este idempotent dacă orice număr de apeluri identice are același efect ca un singur apel. Specificația HTTP (RFC 7231) declară explicit că GET, HEAD, PUT și DELETE sunt idempotente prin natură. POST nu este, din definiție: trimis de două ori, creează două resurse.

Distincția importantă este între idempotency și siguranța operației (safety). O operație sigură nu produce niciun efect secundar (GET este sigur și idempotent). DELETE este idempotent dar nu sigur: primul apel șterge resursa, al doilea nu mai are ce șterge, dar efectul dorit s-a produs o singură dată. Idempotency nu spune nimic despre ce se întâmplă, ci despre câte ori se întâmplă.

  • GET, HEAD, OPTIONS: idempotente și sigure prin specificație.
  • PUT, DELETE: idempotente, nu sigure.
  • POST, PATCH: nici idempotente, nici sigure fără implementare explicită.

Cum implementezi idempotency keys?

Mecanismul standard, popularizat de Stripe, folosește un header Idempotency-Key pe care clientul îl generează o singură dată per operație și îl trimite la fiecare retry. Serverul stochează perechea cheie-rezultat și, la orice apel ulterior cu aceeași cheie, returnează rezultatul salvat fără să reexecute logica.

Pașii de implementare pe backend:

  1. Generare cheie pe client. Clientul creează un UUID v4 sau un hash al parametrilor relevanti ai cererii înainte de primul apel. Cheia trebuie să fie unică per operație logică, nu per request HTTP.
  2. Stocare pe server. La primul apel, serverul verifică dacă există deja un rezultat pentru acea cheie. Dacă nu există, procesează cererea și salvează rezultatul alături de cheie într-o bază de date relațională sau un cache persistent. Dacă există, returnează direct rezultatul salvat.
  3. Returnare consistentă. Apelurile ulterioare cu aceeași cheie trebuie să returneze exact același status code și același corp de răspuns, nu o variantă actualizată. Clientul trebuie să primească iluzia că cererea s-a executat o singură dată.
  4. Expirare. Cheile se șterg după o perioadă fixă (de obicei între 24 de ore și 7 zile). Deduplicarea nu este infinită; e un contract temporal, nu permanent.

Un aspect delicat: salvarea cheii și execuția operației trebuie să fie atomice. Dacă serverul salvează cheia, procesează cererea, dar cade înainte să salveze rezultatul, la al doilea apel cu aceeași cheie serverul nu știe dacă operația s-a terminat sau nu. Soluția este să salvezi cheia cu un status PROCESSING, să execuți operația, apoi să actualizezi la COMPLETED cu rezultatul, totul într-o tranzacție sau cu un mecanism de blocare optimist. Postgres cu SELECT FOR UPDATE sau INSERT ... ON CONFLICT DO NOTHING sunt instrumente directe pentru asta.

Care sunt trade-off-urile (storage, expirare, conflicts)?

Idempotency keys rezolvă o problemă dar introduc altele. Principalele compromisuri:

  • Storage suplimentar. Fiecare cheie trebuie stocată cel puțin până la expirare, împreună cu rezultatul serialzat. La volume mari, asta se acumulează. Dacă operațiile au răspunsuri mari (un PDF generat, o structură JSON complexă), stocarea rezultatelor devine costisitoare. Alternativa este să stochezi doar un identificator al resursei create, nu răspunsul complet, și să reconstituești răspunsul la cerere.
  • Conflicte pe aceeași cheie cu parametri diferiți. Ce faci dacă un client trimite aceeași cheie dar cu un corp diferit? Specificația Stripe recomandă să returnezi 422 Unprocessable Entity în loc să tratezi cererea. Alteori se alege 409 Conflict. Important: nu procesezi cererea nouă cu cheia veche și nu suprascrii rezultatul vechi.
  • Expirate prea devreme. Dacă un client face un retry după 48 de ore iar cheia a expirat la 24, serverul va procesa cererea ca nouă. Asta poate fi comportamentul dorit sau nu, în funcție de context. Perioada de retenție trebuie aleasă în funcție de fereastra realistă de retry a clientului.
  • Operații non-deterministe. Dacă răspunsul include un timestamp sau un ID generat aleator, la primul apel generezi aceste valori; la retried-uri returnezi valorile salvate. Dacă nu stochezi răspunsul complet, poți servi răspunsuri inconsistente la retry.

Monitorizarea cheilor duplicate este utilă: un hit rate ridicat pe deduplicare poate semnala un client cu probleme sau o rețea instabilă care merită investigată în stack-ul de observabilitate.

Cum gestionăm retry-urile la crawlerra?

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ă. Clientul nostru tratează acel 403 ca semnal de rate limiting: așteaptă DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000L (60 de secunde) și reîncearcă o singură dată, propagând eroarea dacă al doilea apel eșuează tot1.

Acest pattern este o aplicare practică limitată a discuției despre idempotență, cu o asumare importantă: tratăm acel 403 ca semnal că cererea a fost respinsă înainte de procesare, ceea ce este comportamentul standard pentru o respingere de rate limit. Pentru o eroare generală pe o operație ne-idempotentă (de exemplu, un 5xx primit la jumătatea procesării), un retry orb fără un mecanism de deduplicare ar putea crea efecte duplicate. Apărarea generală împotriva acestui scenariu rămâne tot idempotency key: clientul trimite aceeași cheie, serverul deduplică, retry-ul devine sigur indiferent unde s-a oprit prima încercare. Erorile de rate limit de la integrările noastre sunt vizibile în stack-ul de observabilitate.

Ce capcane apar la implementare?

  • Cheia generată pe server, nu pe client. Dacă serverul generează cheia la primul apel și o returnează clientului, acesta o poate folosi la retried-uri. Problema: clientul trebuie să facă prima cerere fără protecție, tocmai cea care e cel mai probabil să se piardă dacă rețeaua cade. Cheia trebuie generată pe client înainte de primul apel.
  • Nu stochezi starea intermediară. Dacă serverul procesează cererea și cade după ce a produs efectul extern (a creat comanda în baza de date, a debit cardul) dar înainte să salveze cheia cu rezultatul, la retried-ul următor va procesa din nou. Salvarea cheii trebuie să fie parte din aceeași tranzacție cu operația principală sau să preceadă efectul extern.
  • Tratezi idempotency key ca session token. Cheia de idempotency nu are sens de securitate: oricine o cunoaște poate obține rezultatul operației. Nu o folosi ca mecanism de autorizare sau ca înlocuitor pentru token-uri de autentificare.
  • Aplici idempotency la nivel de HTTP, nu de operație logică. O operație logică poate consta din mai mulți pași (creare contract, trimitere email, actualizare stoc). Protejarea unui singur endpoint cu o cheie nu te protejează dacă pașii sunt distribuiți și parțial executați. Fiecare efect extern are nevoie de propriul mecanism de deduplicare sau de un orchestrator tranzacțional, cum ar fi un workflow n8n cu retry configurat per nod.
  • Nu gestionezi concurența. Două cereri cu aceeași cheie pot ajunge simultan pe server înainte ca prima să fi salvat rezultatul. Fără un lock (optimist sau pesimist) pe cheie, ambele vor procesa și una va suprascrie rezultatul celeilalte. Schematic Postgres cu INSERT ... ON CONFLICT DO NOTHING combinat cu un retry pe client rezolvă scenariul la cost minim.
  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. [crawlerra.smartbill_backoff]

Întrebări frecvente

Ce înseamnă idempotent în practică?

O operație este idempotentă dacă o poți executa de oricâte ori și rezultatul rămâne același ca după prima execuție. Un GET este idempotent din definiție. Un POST de creare nu este, dacă nu implementezi explicit deduplicare. Idempotency keys sunt mecanismul prin care faci un POST idempotent la nivel de aplicație.

Idempotency key versus idempotent HTTP method, care e diferența?

Metodele HTTP idempotente (GET, PUT, DELETE) sunt idempotente prin specificație; idempotency keys fac idempotente operațiile care nu sunt prin natură, cum ar fi POST. Un PUT care înlocuiește complet o resursă este idempotent: l-ai apelat de zece ori cu același corp, resursa arată la fel. Un POST de creare a unei plăți nu e idempotent fără o cheie de deduplicare explicită.

Cât timp trebuie să păstrez un idempotency key?

Atât cât clientul poate face retry în mod rezonabil, de obicei între 24 de ore și 7 zile. Stripe recomandă 24 de ore. Dacă un client a renunțat la o plată după 30 de minute, nu are sens să deduplicezi pentru 30 de zile. Durata de retenție a cheii este un parametru de business, nu tehnic.

Poate un sistem idempotent să garanteze exactly-once delivery?

Nu, idempotency garantează at-least-once cu deduplicare, nu exactly-once. Mesajul poate ajunge de mai multe ori (at-least-once); idempotency key face ca al doilea și al treilea apel să fie fără efect. Exactly-once necesită coordonare distribuită mai complexă (de exemplu, tranzacții în doi timpi) și are un cost de performanță semnificativ.

Cum tratez idempotency cu operații care au efecte secundare externe?

Separi operația principală de efectele secundare și protejezi fiecare cu propriul mecanism de deduplicare. Dacă crearea unui contract declanșează și un email de confirmare, cheia de idempotency protejează crearea contractului. Emailul necesită o altă cheie sau un flag separat care să marcheze că a fost trimis, altfel retried-ul creează contractul corect dar trimite emailul dublu.