Il retry che riceveva per sempre la riga morta

Rinviare la stessa idempotency key restituiva il failed terminale. Cosa ho cambiato in accordo-platform: l’idempotenza vuole una semantica di fallimento.

In breve. In accordo-platform rinviare la stessa idempotency key dopo un fallimento restituiva per sempre la riga failed terminale: solo un UPDATE fatto da un operatore poteva riaccodarla. Ho cambiato la regola: le righe vive o riuscite si ripetono come sono, quelle fallite aprono un successore con una chiave derivata. La tesi, che è un'interpretazione mia e non un fatto: l'idempotenza senza una semantica di fallimento trasforma un errore transitorio in uno stato permanente. Pubblicato il 3 ottobre 2026.

Il sintomo: un retry che non riprovava

Lo abbiamo visto durante il dogfooding, cioè usando la piattaforma sul nostro stesso lavoro. Un import falliva, l'utente riprovava, e il retry veniva reindirizzato alla stessa azione fallita. Non una nuova richiesta: la vecchia, già morta.

Il meccanismo è semplice. Una idempotency key serve a garantire che inviare due volte la stessa richiesta produca un solo effetto. Se la prima richiesta è finita in failed, la seconda con la stessa chiave trovava quella riga e restituiva quella. Corretto per la regola, sbagliato per l'utente. Per riaccodare serviva un UPDATE manuale di un operatore.

Perché la regola era giusta e il risultato sbagliato

L'idempotenza risponde a una domanda: "questa richiesta l'ho già vista?". Non risponde a un'altra: "l'ho vista e non è andata bene, cosa faccio ora?". Quando il sistema ha solo la prima risposta, ogni errore transitorio — un timeout, una dipendenza giù per qualche minuto — diventa definitivo, perché la chiave lo ricorda per sempre.

Il bug non era nel codice che confrontava le chiavi. Era nel contratto: nessuno aveva deciso cosa significa ripetere una richiesta fallita.

Cosa fa ora

La PR #90 di accordo-platform (fix: a retry after failure opens a new request instead of the dead row, commit 3ff5578 e f57495b) scrive il contratto esplicito:

  • le righe live o succeeded si ripetono come sé stesse (replay): stessa risposta, nessun effetto doppio;
  • le righe failed aprono un successore con una chiave derivata, e la storia resta collegata;
  • i casi di richieste non coerenti continuano a essere rifiutati;
  • le righe cancelled restano un replay: una cancellazione è una decisione, non un errore da riprovare;
  • molti retry insieme convergono sullo stesso successore invece di crearne uno per tentativo.

L'ultima riga è quella che dipende dalla concorrenza, e la concorrenza si prova su un database vero, non a mano.

Come l’ho verificato

Qui separo i fatti dalle interpretazioni, come faccio sempre.

Fatti, dal corpo della PR #90:

  • verificato su PostgreSQL 16 reale, incluse corse concorrenti 10x10;
  • i test committati con fixture docker replicano lo stesso scenario;
  • suite non-docker della piattaforma 297/297, test M2 verde, scansione dei segreti pulita;
  • un secondo commit aggiunge evidenza di dogfooding su tre righe JTBD, senza promozioni.

Interpretazione mia: 10x10 non prova che non esistano altre corse; prova che quelle che ho provato convergono. Il numero 297/297 dice che la suite non-docker è verde, non che il contratto sia completo.

I due fix accanto

Due PR vicine toccano lo stesso tema, il fallimento che non deve restare senza uscita, ma con ruoli diversi:

  • la PR #108 espone abandon-exhausted solo all'operatore, per le richieste con un tetto di tentativi: quando i tentativi finiscono, chiudere la richiesta è una scelta umana, non un retry automatico. I deployment token sono rifiutati (403);
  • la PR #94 fa sì che la creazione nella console aspetti la flotta invece di chiedere all'utente di riprovare a mano: il retry manuale resta come ripiego, e vengono mostrati solo i rifiuti terminali.

Tre scelte diverse sullo stesso principio: il retry automatico dove l'errore è transitorio, la decisione umana dove i tentativi sono finiti.

Cosa mi porto via

Quando progetti un sistema agentico, il caso in cui un'azione fallisce e viene riprovata è la norma, non l'eccezione. Prima di scrivere il controllo sulle chiavi conviene scrivere la tabella degli stati: cosa succede a una richiesta live, riuscita, fallita, cancellata, esaurita. Se una cella è vuota, il sistema la riempirà da solo, di solito nel modo peggiore.

È la stessa logica che uso quando ripasso gli errori della fabbrica che ripara software di notte e quando cerco di capire quali problemi del backlog sono davvero veri. Un errore ricordato bene vale solo se il sistema sa cosa farne; ne parlo anche in come le macchine imparano dagli errori.

Domande frequenti

Cos’è una idempotency key?

Un identificatore inviato con una richiesta, che permette al server di riconoscere un invio ripetuto e di non eseguire due volte lo stesso effetto.

Cosa deve succedere se rinvio una richiesta fallita con la stessa chiave?

Dipende dal contratto che scegli. In accordo-platform una riga fallita apre un successore con una chiave derivata e la storia resta collegata; una riuscita o ancora viva viene restituita com’è.

Perché una richiesta cancellata resta un replay?

Perché la cancellazione è una decisione, non un errore transitorio. Riaprirla automaticamente annullerebbe quella decisione.