Un utente clicca un pulsante. La richiesta si blocca. Dieci secondi di silenzio. Clicca il pulsante di fallback. Ora due job sono in esecuzione per una singola intenzione. Si finisce con effetti collaterali duplicati, addebiti doppi e un caos di dati che ti rovina il pomeriggio.
Questo non è un bug del frontend. Un pulsante disabilitato o un timer di debounce in React non ti salveranno. La prima richiesta era già in volo. La rete ha semplicemente inghiottito la risposta. Se il tuo backend tratta ogni richiesta in entrata come una nuova istruzione, i tentativi di riproporre (retries) diventano un rischio. Devi risolvere il problema nel design della tua API e nello schema del tuo database.
La soluzione inizia con una semplice divisione strutturale.
Separare i Job dai Tentativi
Pensa a un job come alla registrazione duratura di ciò che l'utente desidera. Cattura il proprietario, i parametri, il provider di destinazione e l'intenzione esatta. Un tentativo (attempt) è un tentativo specifico di soddisfare quell'intenzione.
Immagina una tipografia. Consegni un file e ti danno il ticket #45. Quel ticket è il job. La tipografia prova con la stampante a getto d'inchiostro. Si inceppa. Questo è il primo tentativo. Spostano il file sulla stampante laser. Questo è il secondo tentativo. Durante tutto il processo, il ticket #45 non cambia mai. Se la tipografia emettesse un nuovo ticket per ogni stampante provata, pagheresti tre volte e riceveresti tre copie indesiderate.
Il tuo database dovrebbe rispecchiare questo schema. Una tabella contiene i job. Un'altra tabella contiene i tentativi. La riga del job rimane costante, mentre i tentativi si accumulano sotto di essa.
Questa separazione ti offre controllo. Ti fornisce anche un posto dove allegare una chiave di idempotenza che sopravviva alle micro-interruzioni di rete.
Richiedere una Chiave di Idempotenza per Ogni Job
Ogni richiesta POST che crea un job deve contenere una chiave di idempotenza univoca. Questa chiave appartiene all'utente, non alla sessione. Combina l'ID del proprietario e la chiave, quindi applica un vincolo di unicità nel database su queste due colonne.
Perché un vincolo del database? Perché controllare l'esistenza nel codice dell'applicazione prima dell'inserimento è una race condition inevitabile. Due richieste identiche possono scivolare attraverso lo stesso intervallo di un microsecondo. Lascia che sia il database a far rispettare la regola. Se un utente invia due volte lo stesso ID proprietario e la stessa chiave, la seconda richiesta intercetta la violazione di unicità e tu restituisci il job esistente. Entrambe le richieste ottengono lo stesso job ID. Non viene avviato alcun lavoro duplicato.
Sii rigoroso riguardo all'ambito (scope). Se qualcuno riutilizza la chiave ma modifica il payload di input, restituisci un conflitto. La chiave di idempotenza deve essere legata a un'intenzione esatta, non solo all'utente. La stessa chiave con un input diverso significa che il client è confuso, e il tuo sistema dovrebbe rifiutarlo invece di cercare di indovinare.
Proteggere le Transizioni di Stato
Un tentativo è una transizione di stato, non un nuovo job. La tua API deve rifiutarsi di generare un nuovo tentativo se un tentativo precedente è ancora bloccato in uno stato di avvio o sconosciuto.
Il motivo sono i timeout. Quando una richiesta al provider va in timeout, il client vede un fallimento, ma il processo lato server potrebbe essere ancora attivo. Il cluster GPU potrebbe stare ancora elaborando la tua richiesta di inferenza. Il container potrebbe stare ancora scrivendo nello storage a oggetti (blob storage). Se segni il tentativo in timeout come fallito e lanci immediatamente un secondo tentativo, stai rischiando effetti collaterali duplicati.
Tratta un timeout come uno stato sconosciuto, non come un fallimento. Blocca i nuovi tentativi finché quello precedente non raggiunge uno stato terminale o non viene esplicitamente annullato da un processo out-of-band. Questa pausa è scomoda. Costringe l'utente ad aspettare. Impedisce inoltre il caos di due worker che mutano le stesse risorse a valle (downstream).
Risolvere le Race Condition con Compare-and-Swap
I problemi più difficili si presentano quando terminano più tentativi contemporaneamente. Magari il tuo sistema ha lanciato il primo tentativo verso il provider primario. Dopo dieci secondi di silenzio, ha lanciato il secondo tentativo verso il fallback. Ora entrambi i tentativi sono conclusi. Non puoi permettere che entrambi scrivano i propri risultati sulla stessa riga del job.
Usa la logica compare-and-swap. Aggiungi un numero di versione alla riga del job. Quando un tentativo termina, esegue un aggiornamento con delle condizioni:
- La versione corrente deve corrispondere a quella letta dal tentativo all'inizio.
- Nessun altro tentativo deve aver già occupato lo slot del risultato.
- Se entrambe le condizioni passano, scrivi il risultato e incrementa la versione.
In termini SQL, questo si traduce in un'istruzione di aggiornamento con un WHERE id = $1 AND version = $2 AND completed_by IS NULL. Se l'aggiornamento restituisce zero righe, un altro tentativo ha già vinto. L'arrivo tardivo deve essere ignorato. Scarta il suo risultato. Non unire. Non aggiungere. Elimina il lavoro. Un risultato tardivo che sovrascrive un vincitore precedente è corruzione di dati, e l'unica mossa sicura è scartarlo.
Questo gestisce in modo pulito la conclusione in ordine inverso. Il tentativo A parte per primo ma ritorna dopo trenta secondi. Il tentativo B parte per secondo ma ritorna dopo cinque secondi. Il tentativo B vince il compare-and-swap. L'aggiornamento del tentativo A non tocca alcuna riga. Il sistema registra la race condition, ignora il payload obsoleto e prosegue.
Testare i punti di rottura
Non troverai questi bug durante i test sul "happy path". La tua suite deve puntare alle fratture.
- Simula un doppio clic. Due richieste POST simultanee con la stessa chiave di idempotenza devono restituire ID di lavoro identici.
- Invia la stessa chiave con un input non corrispondente. Aspettati una risposta di conflitto. Il sistema non deve restituire silenziosamente il lavoro esistente se i parametri differiscono.
- Provoca un timeout. Verifica che il lavoro finisca in uno stato sconosciuto, non in uno stato di errore, e che il sistema blocchi ulteriori tentativi finché l'ambiguità non viene risolta.
- Forza due tentativi a terminare in ordine inverso. Conferma che il secondo a ritornare perda, anche se il primo a partire era il provider primario ufficiale.
Questi test non sono lussi per casi limite. Sono il contratto che la tua API stringe con il resto del sistema.
Valida l'intento del provider prima del failover
Se utilizzi una configurazione multi-provider, potresti essere tentato di trattare diversi modelli AI come slot intercambiabili. Condividono lo stesso percorso di codice, lo stesso client HTTP e lo stesso schema JSON. Ciò non significa che si comportino allo stesso modo.
Un modello potrebbe allucinare una chiave di primo livello. Un altro potrebbe ignorare la formattazione del tuo system prompt. La validazione dello schema intercetta gli errori di sintassi, ma farà passare una risposta che la tua logica di business non può interpretare. Un provider potrebbe restituire un JSON valido che semplicemente esegue l'operazione errata con il tuo prompt template.
Esegui test specifici per il provider prima di consentire lo switch automatico del modello. Conferma che il modello di fallback rispetti effettivamente la struttura di output a una temperatura bassa. Verifica che il tuo prompt venga renderizzato correttamente attraverso il tokenizer di quel provider. Testa l'intero round trip con input reali. Il failover automatico è sicuro solo quando hai dimostrato che il fallback condivide lo stesso contratto operativo.
Mantieni un solo lavoro per intento
I percorsi di fallback sono utili. La moltiplicazione incontrollata dei fallback è un bug. Ogni livello del tuo stack deve valutare se ha già visto esattamente quel task. Il load balancer, l'API handler, il database e il worker devono tutti rispettare la stessa identità.
Progetta il tuo sistema in modo che i retry e i fallback emergano come nuovi tentativi sotto un unico lavoro stabile. Blocca il lavoro con una chiave di idempotenza supportata dal database. Proteggi le transizioni. Metti in competizione i tentativi. Lascia che ne vinca esattamente uno. È così che eviti che un singolo clic dell'utente si trasformi in un intero weekend di pulizia dei dati.
