12 mag 2025·8 min di lettura

Endpoint idempotenti in Go: chiavi, tabelle di dedup e ritenti

Progetta endpoint idempotenti in Go con chiavi di idempotenza, tabelle di dedup in PostgreSQL e handler resistenti ai ritenti per pagamenti, import e webhook.

Endpoint idempotenti in Go: chiavi, tabelle di dedup e ritenti

Perché i ritenti creano duplicati (e perché l'idempotenza è importante)

I ritenti avvengono anche quando non c'è nulla di "rotto". Un client può andare in timeout mentre il server sta ancora lavorando. Una connessione mobile cade e l'app riprova. Un job runner riceve un 502 e ritenta automaticamente la stessa richiesta. Con la consegna almeno-una-volta (comune con code e webhook), i duplicati sono normali.

Ecco perché l'idempotenza conta: richieste ripetute dovrebbero portare allo stesso risultato finale di una singola richiesta.

Alcuni termini si confondono facilmente:

  • Safe: chiamarlo non cambia stato (come una lettura).
  • Idempotent: chiamarlo molte volte ha lo stesso effetto di chiamarlo una sola volta.
  • At-least-once: il mittente ritenta fino a che «attacca», quindi il ricevitore deve gestire i duplicati.

Senza idempotenza, i ritenti possono fare danni reali. Un endpoint di pagamento può addebitare due volte se il primo addebito è andato a buon fine ma la risposta non è mai arrivata al client. Un endpoint di import può creare righe duplicate quando un worker ritenta dopo un timeout. Un handler di webhook può processare lo stesso evento due volte e inviare due email.

Il punto chiave: l'idempotenza è un contratto dell'API, non un dettaglio di implementazione privato. I client devono sapere cosa possono ritentare, quale chiave inviare e quale risposta aspettarsi quando viene rilevato un duplicato. Se cambi il comportamento silenziosamente, rompi la logica di ritentativo e crei nuovi modi di fallimento.

L'idempotenza non sostituisce il monitoring e la riconciliazione. Traccia i tassi di duplicazione, registra le decisioni di “replay” e confronta periodicamente sistemi esterni (come un provider di pagamenti) con il tuo database.

Scegli lo scope di idempotenza e le regole per ogni endpoint

Prima di aggiungere tabelle o middleware, decidi cosa significa "stessa richiesta" e cosa promette il server quando un client ritenta.

La maggior parte dei problemi appare con POST perché spesso crea qualcosa o innesca un effetto collaterale (addebita una carta, invia un messaggio, avvia un import). PATCH può aver bisogno di idempotenza se innesca effetti collaterali, non solo un semplice aggiornamento di campo. GET non dovrebbe cambiare stato.

Definisci lo scope: dove la chiave è unica

Scegli uno scope che corrisponda alle tue regole di business. Troppo ampio blocca lavoro valido. Troppo stretto permette duplicati.

Scope comuni:

  • Per endpoint + cliente
  • Per endpoint + oggetto esterno (per esempio invoice_id o order_id)
  • Per endpoint + tenant (per sistemi multitenant)
  • Per endpoint + metodo di pagamento + importo (solo se le regole del prodotto lo permettono)

Esempio: per un endpoint “Create payment”, rendi la chiave unica per cliente. Per “Ingest webhook event”, scoprila sull'ID evento fornito dal provider (unicità globale dal provider).

Decidi cosa ripetere sui duplicati

Quando arriva un duplicato, restituisci lo stesso esito del primo tentativo riuscito. In pratica, significa riprodurre lo stesso codice di stato HTTP e lo stesso corpo di risposta (o almeno lo stesso ID risorsa e stato).

I client dipendono da questo. Se il primo tentativo è andato a buon fine ma la rete è caduta, il ritentativo non dovrebbe creare un secondo addebito o un secondo job di import.

Scegli una finestra di conservazione

Le chiavi devono scadere. Conservale abbastanza a lungo da coprire ritenti realistici e job ritardati.

  • Pagamenti: 24–72 ore è comune.
  • Import: una settimana può essere ragionevole se gli utenti possono ritentare più tardi.
  • Webhook: allinea la finestra alla politica di ritenti del provider.

Definisci “stessa richiesta”: chiave esplicita vs hash del body

Una chiave di idempotenza esplicita (header o campo) è di solito la regola più pulita.

Un hash del body può aiutare come rete di sicurezza, ma si rompe facilmente con cambi innocui (ordine dei campi, spazi bianchi, timestamp). Se usi hashing, normalizza l'input ed essere rigido su quali campi includere.

Chiavi di idempotenza: come funzionano nella pratica

Una chiave di idempotenza è un semplice contratto tra client e server: “Se vedi questa chiave di nuovo, trattala come la stessa richiesta.” È uno degli strumenti più pratici per API resistenti ai ritenti.

La chiave può venire da entrambi i lati, ma per la maggior parte delle API dovrebbe essere generata dal client. Il client sa quando sta ritentando la stessa azione, quindi può riusare la stessa chiave tra i tentativi. Le chiavi generate dal server aiutano quando crei per prima una risorsa "draft" (come un job di import) e poi permetti ai client di ritentare riferendosi a quell'ID di job, ma non aiutano sul primissimo tentativo.

Usa una stringa casuale e non intuibile. Mira ad almeno 128 bit di entropia (per esempio 32 caratteri esadecimali o un UUID). Non costruire chiavi da timestamp o user ID.

Sul server, memorizza la chiave con contesto sufficiente per rilevare usi impropri e riprodurre il risultato originale:

  • Chi ha fatto la chiamata (account o user ID)
  • A quale endpoint o operazione si applica
  • Un hash dei campi di richiesta importanti
  • Stato corrente (in-progress, succeeded, failed)
  • La risposta da riprodurre (codice di stato e body)

Una chiave dovrebbe essere scoped, tipicamente per utente (o per token API) più endpoint. Se la stessa chiave è riusata con un payload diverso, rigettala con un errore chiaro. Questo previene collisioni accidentali in cui un client buggy invia un nuovo import con una chiave vecchia.

Al replay, restituisci lo stesso risultato del primo tentativo riuscito. Questo significa lo stesso codice di stato HTTP e lo stesso corpo di risposta, non una lettura fresca che potrebbe essere cambiata.

Tabelle di dedup in PostgreSQL: un pattern semplice e affidabile

Una tabella di dedup dedicata è uno dei modi più semplici per implementare l'idempotenza. La prima richiesta crea una riga per la chiave di idempotenza. Ogni ritentativo legge la stessa riga e restituisce il risultato memorizzato.

Cosa memorizzare

Mantieni la tabella piccola e focalizzata. Una struttura comune:

  • key: la chiave di idempotenza (text)
  • owner: a chi appartiene la chiave (user_id, account_id o client API ID)
  • request_hash: un hash dei campi di richiesta importanti
  • response: il payload di risposta finale (spesso JSON) o un puntatore a un risultato memorizzato
  • created_at: quando la chiave è stata vista per la prima volta

Il vincolo unico è il cuore del pattern. Applica unicità su (owner, key) così un client non può creare duplicati e due client diversi non collidono.

Memorizza anche un request_hash così puoi rilevare usi impropri della chiave. Se arriva un ritentativo con la stessa chiave ma hash diverso, restituisci un errore invece di mescolare due operazioni diverse.

Conservazione e indicizzazione

Le righe di dedup non dovrebbero vivere per sempre. Conservale abbastanza da coprire le reali finestre di retry, poi puliscile.

Per velocità sotto carico:

  • Indice unico su (owner, key) per insert/lookup rapido
  • Indice opzionale su created_at per rendere la pulizia economica

Se la risposta è grande, memorizza un puntatore (per esempio un result ID) e conserva il payload completo altrove. Questo riduce il bloat della tabella mantenendo un comportamento di retry coerente.

Passo-passo: un handler sicuro ai ritenti in Go

Rendi gli import friendly ai ritenti
Crea job di import restartabili che restituiscono lo stesso job ID sui ritenti.
Costruisci ora

Un handler sicuro ai ritenti ha due cose: un modo stabile per identificare “la stessa richiesta di nuovo” e un posto durevole dove salvare il primo esito così da poterlo riprodurre.

Un flusso pratico per pagamenti, import e ingestione webhook:

  1. Valida la richiesta, poi deriva tre valori: una chiave di idempotenza (da un header o campo cliente), un owner (tenant o user ID) e un request hash (hash dei campi importanti).

  2. Avvia una transazione di database e prova a creare un record di dedup. Rendilo unico su (owner, key). Memorizza request_hash, stato (started, completed) e segnaposto per la risposta.

  3. Se l'insert confligge, carica la riga esistente. Se è completed, restituisci la risposta salvata. Se è started, o aspetta brevemente (polling semplice) o restituisci 409/202 così il client ritenta più tardi.

  4. Solo quando hai effettivamente “preso in carico” la riga di dedup, esegui la logica di business una sola volta. Scrivi gli effetti collaterali dentro la stessa transazione quando possibile. Persiste il risultato di business più la risposta HTTP (codice di stato e body).

  5. Commit, e registra con la chiave di idempotenza e l'owner così il supporto può tracciare i duplicati.

Un pattern minimo per la tabella:

create table idempotency_keys (
  owner_id text not null,
  idem_key text not null,
  request_hash text not null,
  status text not null,
  response_code int,
  response_body jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  primary key (owner_id, idem_key)
);

Esempio: un endpoint “Create payout” va in timeout dopo aver effettuato l'addebito. Il client ritenta con la stessa chiave. L'handler incontra il conflitto, vede un record completed e restituisce l'ID del payout originale senza addebitare di nuovo.

Pagamenti: addebitare una sola volta, anche con timeout

Nei pagamenti l'idempotenza smette di essere opzionale. Le reti falliscono, le app mobile ritentano e i gateway a volte vanno in timeout dopo aver già creato l'addebito.

Una regola pratica: la chiave di idempotenza protegge la creazione dell'addebito, e l'ID del provider di pagamento (charge/intent ID) diventa la fonte di verità dopo di ciò. Una volta salvato un provider ID, non creare un nuovo addebito per la stessa richiesta.

Un pattern che gestisce ritenti e incertezza del gateway:

  • Leggi e valida la chiave di idempotenza.
  • In una transazione di database, crea o recupera una riga di pagamento indicizzata da (merchant_id, idempotency_key). Se ha già un provider_id, restituisci il risultato salvato.
  • Se non esiste un provider_id, chiama il gateway per creare un PaymentIntent/Charge.
  • Se il gateway va a buon fine, persisti provider_id e marca il pagamento come “succeeded” (o “requires_action”).
  • Se il gateway va in timeout o ritorna un risultato sconosciuto, memorizza lo stato “pending” e restituisci una risposta consistente che dica al client che è sicuro ritentare.

Il dettaglio chiave è come tratti i timeout: non assumere fallimento. Marca il pagamento come pending, poi conferma interrogando il gateway più tardi (o tramite webhook) usando il provider ID una volta che lo hai.

Le risposte di errore dovrebbero essere prevedibili. I client costruiscono logiche di retry attorno a quello che restituisci, quindi mantieni codici di stato e forme di errore stabili.

Import e endpoint batch: dedup senza perdere progresso

Distribuisci una soluzione completa
Spedisci backend, web app e app native nel cloud che preferisci.
Deploy app

Gli import sono dove i duplicati fanno più male. Un utente carica un CSV, il server va in timeout al 95% e rilancia il retry. Senza un piano, o crei righe duplicate o lo costringi a ricominciare.

Per il lavoro batch, pensa a due livelli: il job di import e gli item dentro di esso. L'idempotenza a livello job evita che la stessa richiesta crei più job. L'idempotenza a livello item impedisce che la stessa riga venga applicata due volte.

Un pattern a livello job è richiedere una chiave di idempotenza per ogni richiesta di import (o derivarla da un request hash stabile più user ID). Salvala con un record import_job e restituisci lo stesso job ID sui ritenti. L'handler dovrebbe poter dire, “Ho già visto questo job, ecco il suo stato corrente,” invece di “ricomincia.”

Per la dedup a livello riga, affidati a una chiave naturale già presente nei dati. Per esempio, ogni riga potrebbe avere un external_id dal sistema sorgente, o una combinazione stabile come (account_id, email). Applicala con un vincolo unico in PostgreSQL e usa upsert in modo che i ritenti non creino duplicati.

Prima di spedire, decidi cosa fa un replay quando una riga esiste già. Sii esplicito: salta, aggiorna campi specifici o fallisci. Evita il "merge" a meno che non hai regole molto chiare.

Il successo parziale è normale. Invece di restituire un grande “ok” o “failed”, memorizza risultati per riga legati al job: numero di riga, chiave naturale, stato (created, updated, skipped, error) e un messaggio d'errore. Su un ritentativo, puoi rieseguire in sicurezza mantenendo gli stessi risultati per le righe già completate.

Per rendere gli import riprendibili, aggiungi checkpoint. Processa a pagine (per esempio 500 righe alla volta), memorizza il cursore dell'ultimo processato (indice riga o cursore sorgente) e aggiornalo dopo che ogni pagina è committata. Se il processo crasha, il tentativo successivo riprende dall'ultimo checkpoint.

Ingestione webhook: dedup, valida, poi processa in sicurezza

Implementa il flusso di replay
Usa il Business Process Editor per gestire i percorsi in corso, completati e di conflitto.
Aggiungi logica

I mittenti di webhook ritentano. Inoltre inviano eventi fuori ordine. Se il tuo handler aggiorna lo stato a ogni consegna, alla fine ricreerai record, invierai email doppie o addebiterai due volte.

Inizia scegliendo la migliore chiave di dedup. Se il provider ti dà un ID evento unico, usalo. Usane un hash del payload solo se non c'è un ID evento.

La sicurezza viene prima: verifica la signature prima di accettare qualsiasi cosa. Se la firma fallisce, rigetta la richiesta e non scrivere una riga di dedup. Altrimenti un attaccante potrebbe “prenotare” un ID evento e bloccare i veri eventi più tardi.

Un flusso sicuro sotto i ritenti:

  • Verifica la signature e la forma base (header richiesti, event ID).
  • Inserisci l'event ID in una tabella di dedup con vincolo unico.
  • Se l'insert fallisce per duplicato, restituisci 200 immediatamente.
  • Memorizza il payload raw (e gli header) quando è utile per audit e debugging.
  • Enqueue il processamento e restituisci 200 velocemente.

Rispondere rapidamente è importante perché molti provider hanno timeout brevi. Fai il lavoro minimo affidabile nella richiesta: verifica, dedup, persisti. Poi processa in modo asincrono (worker, queue, background job). Se non puoi fare async, mantieni il processamento idempotente keyando gli effetti collaterali interni allo stesso event ID.

La consegna fuori ordine è normale. Non dare per scontato che “created” arrivi prima di “updated.” Preferisci upsert per ID oggetto esterno e traccia l'ultimo timestamp o versione processata.

Conservare i payload raw aiuta quando un cliente dice “non abbiamo mai ricevuto l'aggiornamento.” Puoi rieseguire il processing dal body salvato dopo aver corretto un bug, senza chiedere al provider di reinviare.

Concorrenza: rimanere corretti sotto richieste parallele

I ritenti diventano complicati quando due richieste con la stessa chiave arrivano contemporaneamente. Se entrambi gli handler eseguono lo step “do work” prima che uno salvi il risultato, puoi ancora ottenere doppio addebito, doppio import o doppia enqueue.

Il punto di coordinamento più semplice è la transazione del database. Fai il primo step “claim the key” e lascia che il database decida chi vince. Opzioni comuni:

  • Insert unico in una tabella di dedup (il database fa rispettare un solo vincitore)
  • SELECT ... FOR UPDATE dopo aver creato (o trovato) la riga di dedup
  • Lock advisory a livello di transazione chiave su un hash della idempotency key
  • Vincoli unici sul record di business come ultima difesa

Per lavoro di lunga durata, evita di tenere un row lock mentre chiami sistemi esterni o esegui import di minuti. Invece, memorizza una piccola macchina a stati nella riga di dedup così altre richieste possono uscire velocemente.

Un set pratico di stati:

  • in_progress con started_at
  • completed con risposta cache
  • failed con un codice di errore (opzionale, a seconda della politica di ritenti)
  • expires_at (per la pulizia)

Esempio: due istanze dell'app ricevono la stessa richiesta di pagamento. Istanza A inserisce la chiave e marca in_progress, poi chiama il provider. Istanza B incontra il percorso di conflitto, legge la riga di dedup, vede in_progress e restituisce una risposta rapida “ancora in elaborazione” (o aspetta brevemente e ricontrolla). Quando A termina, aggiorna la riga a completed e salva il corpo di risposta così i ritenti successivi ottengono esattamente lo stesso output.

Errori comuni che rompono l'idempotenza

Genera un backend in Go
Genera un backend in Go che mantenga gli effetti collaterali coerenti rispetto a timeout e ritenti.
Crea backend

La maggior parte dei bug di idempotenza non riguarda locking sofisticato. Sono scelte “quasi corrette” che falliscono sotto ritenti, timeout o due utenti che fanno azioni simili.

Una trappola comune è trattare la chiave di idempotenza come globalmente unica. Se non la scopi (per utente, account o endpoint), due client diversi possono collidere e uno riceverà il risultato dell'altro.

Un altro problema è accettare la stessa chiave con un body diverso. Se la prima chiamata era per $10 e il replay è per $100, non dovresti restituire silenziosamente il primo risultato. Memorizza un request_hash (o campi chiave), confronta al replay e restituisci un errore di conflitto chiaro.

I client si confondono anche quando i replay restituiscono un shape di risposta o un codice di stato differente. Se la prima chiamata ha restituito 201 con un body JSON, il replay dovrebbe restituire lo stesso body e lo stesso codice di stato. Cambiare il comportamento del replay costringe i client a indovinare.

Errori che frequentemente causano duplicati:

  • Affidarsi solo a una mappa in memoria o cache e perdere lo stato di dedup al riavvio.
  • Usare una chiave senza scope (collisioni cross-user o cross-endpoint).
  • Non validare mismatch di payload per la stessa chiave.
  • Eseguire prima l'effetto collaterale (charge, insert, publish) e scrivere il record di dedup dopo.
  • Restituire un nuovo ID generato a ogni ritento invece di riprodurre il risultato originale.

Una cache può velocizzare le letture, ma la fonte di verità dovrebbe essere durevole (di solito PostgreSQL). Altrimenti i ritenti dopo un deploy possono creare duplicati.

Pianifica anche la pulizia. Se conservi ogni chiave per sempre, le tabelle crescono e gli indici rallentano. Imposta una finestra di ritenzione basata sul comportamento reale di retry, elimina le righe vecchie e mantieni l'indice unico piccolo.

Checklist rapida e prossimi passi

Tratta l'idempotenza come parte del contratto API. Ogni endpoint che può essere ritentato da un client, una coda o un gateway necessita di una regola chiara su cosa significa “stessa richiesta” e su cosa significa “stesso risultato”.

Una checklist prima di andare in produzione:

  • Per ogni endpoint retryable, lo scope di idempotenza è definito (per user, per account, per ordine, per evento esterno) e documentato?
  • La dedup è applicata dal database (vincolo unico sulla chiave di idempotenza e sullo scope), non solo “controllata nel codice”?
  • Al replay, restituisci lo stesso codice di stato e body (o un sottoinsieme documentato e stabile), non un oggetto nuovo o un timestamp diverso?
  • Per i pagamenti, gestisci in modo sicuro gli esiti sconosciuti (timeout dopo l'invio, gateway dice “processing”) senza addebitare due volte?
  • Log e metriche rendono chiaro quando una richiesta è stata vista per la prima volta vs quando è stata riprodotta?

Se qualche voce è “forse”, sistemala ora. La maggior parte dei fallimenti appare sotto stress: ritenti paralleli, reti lente e outage parziali.

Se stai costruendo strumenti interni o app per clienti su AppMaster (appmaster.io), è utile progettare fin da subito chiavi di idempotenza e la tabella di dedup in PostgreSQL. In questo modo, anche quando la piattaforma rigenera codice backend in Go al cambiare dei requisiti, il comportamento di retry resta consistente.

FAQ

Perché i ritenti creano addebiti doppi o record duplicati anche quando la mia API è corretta?

I ritenti sono normali perché le reti e i client falliscono in modi ordinari. Una richiesta può andare a buon fine sul server ma la risposta non raggiunge il client, così il client ritenta e si finisce per eseguire lo stesso lavoro due volte a meno che il server non riconosca e riesegua il risultato originale.

Cosa dovrei usare come chiave di idempotenza e chi dovrebbe generarla?

Invia la stessa chiave ad ogni ritentativo della stessa azione. Generala sul client come stringa casuale e non indovinabile (per esempio un UUID) e non riutilizzarla per un'azione diversa.

Come dovrei scoprire la portata delle chiavi di idempotenza per evitare collisioni tra utenti o tenant?

Scalala per rispecchiare la tua regola di business, di solito per endpoint più un'identità del chiamante come user, account, tenant o token API. Questo evita che due clienti diversi collidano sulla stessa chiave e si scambino i risultati.

Cosa dovrebbe restituire la mia API quando riceve una richiesta duplicata con la stessa chiave?

Restituisci lo stesso esito del primo tentativo riuscito. In pratica, fai il replay dello stesso codice di stato HTTP e dello stesso corpo di risposta, o almeno dello stesso ID risorsa e stato, così i client possono ritentare senza generare un secondo effetto collaterale.

Cosa succede se il client riutilizza per errore la stessa chiave di idempotenza con un payload diverso?

Rigetta con un errore chiaro in stile conflitto invece di indovinare. Memorizza e confronta un hash dei campi importanti della richiesta e, se la chiave coincide ma il payload è diverso, fallisci subito per evitare di mescolare due operazioni diverse sotto la stessa chiave.

Per quanto tempo dovrei conservare le chiavi di idempotenza nel database?

Conserva le chiavi abbastanza a lungo da coprire i ritenti realistici, poi eliminale. Un valore comune è 24–72 ore per i pagamenti, una settimana per gli import e per i webhook allinea la finestra alla politica di ritenti del mittente in modo che i ritenti tardivi siano ancora deduplicati correttamente.

Qual è lo schema PostgreSQL più semplice per l'idempotenza?

Una tabella di dedup dedicata funziona bene perché il database può far rispettare una vincolo unico e sopravvivere ai riavvii. Memorizza lo scope del proprietario, la chiave, un request hash, uno stato e la risposta da riprodurre, quindi rendi (owner, key) unico in modo che una sola richiesta "vince".

Come gestisco due richieste identiche che arrivano contemporaneamente?

Rivendica la chiave dentro una transazione di database prima di fare l'effetto collaterale. Se un'altra richiesta arriva in parallelo, dovrebbe incontrare il vincolo unico, vedere in_progress o completed e restituire una risposta di attesa/replay invece di eseguire la logica due volte.

Come evito di addebitare due volte quando il gateway di pagamento va in timeout?

Considera i timeout come “sconosciuti”, non come fallimenti. Registra uno stato pending e, se hai un provider ID, usalo come fonte di verità così i ritenti restituiscono lo stesso risultato di pagamento invece di crearne uno nuovo.

Come posso rendere gli import resistenti ai ritenti senza costringere gli utenti a ricominciare o creare duplicati?

Fai dedup a due livelli: job-level e item-level. I ritenti devono restituire lo stesso job ID e applicare una chiave naturale per le righe (come un external ID o (account_id, email)) con vincoli unici o upsert in modo che la rielaborazione non crei duplicati.

Facile da avviare
Creare qualcosa di straordinario

Sperimenta con AppMaster con un piano gratuito.
Quando sarai pronto potrai scegliere l'abbonamento appropriato.

Iniziare