12 mai 2025·8 min de lecture

Points de terminaison idempotents en Go : clés, tables de déduplication et réessais

Concevez des endpoints idempotents en Go avec des clés d'idempotence, des tables de déduplication et des handlers sûrs pour les paiements, imports et webhooks.

Points de terminaison idempotents en Go : clés, tables de déduplication et réessais

Pourquoi les réessais créent des doublons (et pourquoi l'idempotence est importante)

Les réessais surviennent même lorsque rien n'est "cassé". Un client expire alors que le serveur travaille encore. Une connexion mobile coupe et l'application réessaie. Un orchestrateur renvoie une 502 et renvoie automatiquement la même requête. Avec un modèle "au moins une fois" (courant avec les queues et les webhooks), les doublons sont normaux.

C'est pour ça que l'idempotence est essentielle : des requêtes répétées doivent mener au même résultat final que la requête unique.

Quelques termes sont faciles à confondre :

  • Safe : l'appel ne change pas l'état (comme une lecture).
  • Idempotent : appeler plusieurs fois a le même effet que l'appeler une seule fois.
  • At-least-once : l'émetteur réessaie jusqu'à obtenir un effet persistant, donc le récepteur doit gérer les duplicatas.

Sans idempotence, les réessais peuvent causer de vrais dégâts. Un endpoint de paiement peut débiter deux fois si le premier débit a réussi mais que la réponse n'est pas parvenue au client. Un endpoint d'import peut créer des lignes dupliquées lorsqu'un worker réessaye après un timeout. Un handler de webhook peut traiter deux fois le même événement et envoyer deux emails.

Point clé : l'idempotence est un contrat d'API, pas un détail d'implémentation privé. Les clients doivent savoir ce qu'ils peuvent réessayer, quelle clé envoyer et quelle réponse attendre quand un doublon est détecté. Si vous changez le comportement sans le documenter, vous cassez la logique de réessai et créez de nouveaux modes de défaillance.

L'idempotence ne remplace pas non plus la supervision et la réconciliation. Suivez les taux de doublons, logguez les décisions de "replay" et comparez périodiquement les systèmes externes (comme un prestataire de paiement) avec votre base de données.

Choisir le périmètre d'idempotence et les règles par endpoint

Avant d'ajouter des tables ou du middleware, décidez ce que signifie "même requête" et ce que votre serveur promet de faire quand un client réessaye.

La plupart des problèmes apparaissent sur les POST car ils créent quelque chose ou déclenchent un effet de bord (prélever une carte, envoyer un message, lancer un import). PATCH peut aussi nécessiter de l'idempotence si cela déclenche des effets, pas seulement une mise à jour de champ. GET ne doit pas changer l'état.

Définir le périmètre : où la clé est unique

Choisissez un scope qui correspond à vos règles métier. Trop large bloque du travail valide. Trop étroit permet les doublons.

Scopes courants :

  • Par endpoint + client
  • Par endpoint + objet externe (par exemple invoice_id ou order_id)
  • Par endpoint + tenant (pour les systèmes multi-tenant)
  • Par endpoint + moyen de paiement + montant (seulement si vos règles produit l'autorisent)

Exemple : pour un endpoint "Create payment", rendez la clé unique par client. Pour "Ingest webhook event", scopez-la par l'ID d'événement du fournisseur (unicité globale fournie par le fournisseur).

Décider du comportement sur les doublons

Quand un doublon arrive, renvoyez le même résultat que la première tentative réussie. En pratique, cela signifie rejouer le même code HTTP et le même corps de réponse (ou au moins le même ID de ressource et le même état).

Les clients comptent là-dessus. Si la première tentative a réussi mais que le réseau a coupé, le réessai ne doit pas créer un deuxième prélèvement ou un deuxième job d'import.

Choisir une fenêtre de rétention

Les clés doivent expirer. Conservez-les assez longtemps pour couvrir les réessais réalistes et les jobs retardés.

  • Paiements : 24 à 72 heures est courant.
  • Imports : une semaine peut être raisonnable si les utilisateurs réessaient plus tard.
  • Webhooks : alignez avec la politique de réessai du fournisseur.

Définir "même requête" : clé explicite vs hash du corps

Une clé d'idempotence explicite (header ou champ) est généralement la règle la plus propre.

Un hash du corps peut aider en dernier recours, mais il se casse facilement avec des changements innocents (ordre des champs, espaces, timestamps). Si vous utilisez le hashing, normalisez l'entrée et soyez strict sur les champs inclus.

Clés d'idempotence : comment elles fonctionnent en pratique

Une clé d'idempotence est un simple contrat entre client et serveur : "Si vous voyez cette clé à nouveau, traitez-la comme la même requête." C'est un des outils les plus pratiques pour des API tolérantes aux réessais.

La clé peut venir de l'une ou l'autre partie, mais pour la plupart des APIs elle devrait être générée côté client. Le client sait quand il réessaie la même action, il peut donc réutiliser la même clé entre les tentatives. Les clés générées côté serveur aident quand vous créez d'abord une ressource "draft" (par exemple un job d'import) et laissez ensuite les clients réessayer en référant cet ID, mais elles n'aident pas pour la toute première requête.

Utilisez une chaîne aléatoire et difficile à deviner. Visez au moins 128 bits d'entropie (par exemple 32 caractères hex ou un UUID). N'utilisez pas de clés construites à partir de timestamps ou d'IDs utilisateurs.

Côté serveur, stockez la clé avec suffisamment de contexte pour détecter les mauvais usages et rejouer le résultat original :

  • Qui a fait l'appel (account ou user ID)
  • Sur quel endpoint/opération elle s'applique
  • Un hash des champs importants de la requête
  • Statut courant (en cours, réussi, échoué)
  • La réponse à rejouer (code et body)

Une clé doit être scoped, typiquement par utilisateur (ou par token API) + endpoint. Si la même clé est réutilisée avec une payload différente, rejetez-la avec une erreur claire. Cela évite les collisions accidentelles où un client buggy envoie un nouveau montant avec une ancienne clé.

Au replay, renvoyez le même résultat que lors de la première tentative réussie. Cela signifie le même code HTTP et le même corps de réponse, pas une lecture fraîche qui aurait pu évoluer.

Tables de déduplication dans PostgreSQL : un pattern simple et fiable

Une table dédiée de déduplication est l'un des moyens les plus simples pour implémenter l'idempotence. La première requête crée une ligne pour la clé d'idempotence. Chaque réessai lit cette même ligne et renvoie le résultat stocké.

Que stocker

Gardez la table petite et ciblée. Une structure courante :

  • key : la clé d'idempotence (text)
  • owner : à qui appartient la clé (user_id, account_id ou ID du client API)
  • request_hash : un hash des champs importants de la requête
  • response : le payload final de réponse (souvent JSON) ou un pointeur vers le résultat stocké
  • created_at : quand la clé a été vue pour la première fois

La contrainte d'unicité est le cœur du pattern. Faites l'unicité sur (owner, key) pour qu'un seul client ne puisse pas créer de doublons, et que deux clients différents ne se percutent pas.

Stockez aussi un request_hash pour détecter les mauvais usages. Si un réessai arrive avec la même clé mais un hash différent, renvoyez une erreur au lieu de mélanger deux opérations différentes.

Rétention et indexation

Les lignes de dédup ne doivent pas vivre éternellement. Conservez-les assez longtemps pour couvrir les fenêtres de réessai réelles, puis nettoyez-les.

Pour la vitesse sous charge :

  • Index unique sur (owner, key) pour des insertions/lectures rapides
  • Index optionnel sur created_at pour faciliter le nettoyage

Si la réponse est volumineuse, stockez un pointeur (par exemple un result ID) et conservez le payload complet ailleurs. Cela réduit la croissance de la table tout en gardant un comportement de réessai cohérent.

Étape par étape : un handler tolérant aux réessais en Go

Traitez les webhooks en toute sécurité
Dédupliquez les événements entrants par ID d'événement fournisseur avant de déclencher des effets de bord.
Gérer les webhooks

Un handler résistant aux réessais a besoin de deux choses : un moyen stable d'identifier "la même requête à nouveau" et un emplacement durable pour stocker le premier résultat afin de pouvoir le rejouer.

Un flux pratique pour paiements, imports et ingestion de webhooks :

  1. Validez la requête, puis déduisez trois valeurs : une clé d'idempotence (depuis un header ou un champ client), un owner (tenant ou user ID) et un request hash (hash des champs importants).

  2. Démarrez une transaction DB et essayez de créer un enregistrement de dédup. Rendez-le unique sur (owner, key). Stockez request_hash, le statut (started, completed) et des placeholders pour la réponse.

  3. Si l'insert entre en conflit, chargez la ligne existante. Si elle est completed, renvoyez la réponse stockée. Si elle est started, attendez brièvement (polling simple) ou renvoyez 409/202 pour que le client réessaie plus tard.

  4. Ce n'est que lorsque vous "possédez" la ligne de dédup que vous exécutez la logique métier une fois. Écrivez les effets de bord à l'intérieur de la même transaction quand c'est possible. Persistez le résultat métier ainsi que la réponse HTTP (code et body).

  5. Committez, et logguez avec la clé d'idempotence et l'owner pour que le support puisse tracer les doublons.

Un pattern minimal de table :

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)
);

Exemple : un endpoint "Create payout" timeoute après avoir facturé. Le client réessaie avec la même clé. Votre handler rencontre le conflit, voit une ligne completed et renvoie l'ID du payout original sans re-facturer.

Paiements : débiter exactement une fois, même en cas de timeout

Les paiements sont l'endroit où l'idempotence cesse d'être optionnelle. Les réseaux échouent, les apps mobiles réessayent, et les passerelles time-outent parfois après avoir déjà créé la transaction.

Règle pratique : la clé d'idempotence protège la création du débit, et l'ID du fournisseur (charge/intent ID) devient la source de vérité après coup. Une fois que vous stockez un provider ID, ne créez pas de nouveau débit pour la même requête.

Un pattern qui gère réessais et incertitude des passerelles :

  • Lisez et validez la clé d'idempotence.
  • Dans une transaction DB, créez ou récupérez une ligne de payment keyée par (merchant_id, idempotency_key). Si elle contient déjà un provider_id, renvoyez le résultat sauvegardé.
  • Si aucun provider_id n'existe, appelez la passerelle pour créer un PaymentIntent/Charge.
  • Si la passerelle réussit, persistez le provider_id et marquez le paiement comme "succeeded" (ou "requires_action").
  • Si la passerelle timeoute ou retourne un résultat inconnu, enregistrez le statut "pending" et renvoyez une réponse cohérente qui indique au client qu'il est sûr de réessayer.

Le détail clé est le traitement des timeouts : ne présumez pas l'échec. Marquez le paiement comme pending, puis confirmez en interrogeant la passerelle plus tard (ou via webhook) en utilisant le provider ID une fois disponible.

Les réponses d'erreur doivent être prévisibles. Les clients construisent leur logique de réessai sur ce que vous renvoyez, gardez donc les codes et formes d'erreur stables.

Imports et endpoints batch : dédupliquer sans perdre la progression

Construisez votre prochaine application
Transformez votre contrat d'API en un outil interne ou un portail client sans lourds boilerplates.
Commencer

Les imports sont là où les doublons font le plus mal. Un utilisateur téléverse un CSV, votre serveur timeoute à 95% et il réessaye. Sans plan, vous créez soit des lignes doublons, soit vous forcez à tout recommencer.

Pour le travail en batch, pensez en deux couches : le job d'import et les items à l'intérieur. L'idempotence au niveau du job empêche la création de jobs en double. L'idempotence au niveau des items empêche l'application d'une même ligne deux fois.

Un pattern job-level est d'exiger une clé d'idempotence par requête d'import (ou d'en déduire une depuis un hash stable + user ID). Stockez-la dans un enregistrement import_job et renvoyez le même job ID lors des réessais. Le handler doit pouvoir répondre "J'ai déjà vu ce job, voici son état actuel" au lieu de "recommencez".

Pour la dédup item-level, reposez-vous sur une clé naturelle déjà présente dans les données. Par exemple chaque ligne peut inclure un external_id du système source, ou une combinaison stable comme (account_id, email). Appliquez une contrainte unique dans PostgreSQL et utilisez un upsert pour que les réessais n'ajoutent pas de doublons.

Avant le lancement, décidez ce que fait un replay quand une ligne existe déjà. Soyez explicite : skip, mettre à jour des champs spécifiques, ou échouer. Évitez le "merge" à moins d'avoir des règles très claires.

Le succès partiel est normal. Au lieu de retourner un grand "ok" ou "failed", stockez des résultats par ligne liés au job : numéro de ligne, clé naturelle, statut (created, updated, skipped, error) et message d'erreur. Lors d'un réessai, vous pouvez relancer en toute sécurité tout en conservant les mêmes résultats pour les lignes déjà traitées.

Pour rendre les imports redémarrables, ajoutez des checkpoints. Traitez par pages (par exemple 500 lignes à la fois), stockez le dernier curseur traité (index de ligne ou curseur source) et mettez-le à jour après chaque commit de page. Si le process plante, la tentative suivante reprendra depuis le dernier checkpoint.

Ingestion de webhooks : dédupliquer, valider, puis traiter en sécurité

Concevez pour évoluer en toute sécurité
Intégrez l'idempotence dès la conception de l'API, puis régénérez du code propre au fil des changements.
Commencer la construction

Les expéditeurs de webhooks réessaient. Ils envoient aussi parfois des événements hors ordre. Si votre handler met à jour l'état à chaque livraison, vous finirez par créer en double des enregistrements, renvoyer deux emails ou débiter deux fois.

Commencez par choisir la meilleure clé de déduplication. Si le fournisseur vous donne un ID d'événement unique, utilisez-le. N'utilisez un hash du payload que si aucun ID d'événement n'est fourni.

La sécurité passe d'abord : vérifiez la signature avant d'accepter quoi que ce soit. Si la signature échoue, rejetez la requête et n'écrivez pas de ligne de dédup. Sinon, un attaquant pourrait "réserver" un ID d'événement et bloquer les événements réels plus tard.

Un flux sûr sous réessais :

  • Vérifiez la signature et la forme basique (headers requis, event ID).
  • Insérez l'event ID dans une table de dédup avec contrainte d'unicité.
  • Si l'insert échoue pour duplicate, renvoyez 200 immédiatement.
  • Stockez le payload brut (et les headers) quand c'est utile pour l'audit et le debugging.
  • Mettez en file le traitement et répondez 200 rapidement.

Acknowledgez rapidement : beaucoup de fournisseurs ont des timeouts courts. Faites le travail minimal fiable dans la requête : vérification, dédup, persistance. Puis traitez de façon asynchrone (worker, queue, job background). Si vous ne pouvez pas faire d'asynchrone, maintenez le traitement idempotent en clefant les effets de bord internes sur le même event ID.

La livraison hors-ordre est normale. Ne supposez pas que "created" arrive avant "updated". Préférez les upserts par external object ID et suivez le dernier timestamp ou la version d'événement traitée.

Conserver les payloads bruts aide quand un client dit "nous n'avons jamais reçu la mise à jour". Vous pouvez relancer le traitement depuis le corps stocké après avoir corrigé un bug, sans demander au fournisseur de renvoyer.

Concurrence : rester correct face aux requêtes parallèles

Les réessais deviennent compliqués quand deux requêtes avec la même clé arrivent en parallèle. Si les deux handlers exécutent l'étape "do work" avant que l'un ou l'autre n'écrive le résultat, vous pouvez encore doubler les actions.

Le point de coordination le plus simple est la transaction de base de données. Faites du premier pas "réclamer la clé" et laissez la DB décider qui gagne. Options courantes :

  • Insert unique dans une table de dédup (la DB fait gagner un seul insertant)
  • SELECT ... FOR UPDATE après création (ou recherche) de la ligne de dédup
  • Verrous d'advisory transactionnels basés sur un hash de la clé d'idempotence
  • Contraintes uniques sur l'enregistrement métier comme filet de sécurité final

Pour un travail long, évitez de tenir un verrou de ligne pendant que vous appelez des systèmes externes ou exécutez des imports de plusieurs minutes. Stockez plutôt une petite machine d'états dans la ligne de dédup pour que les autres requêtes sortent vite.

Un ensemble pratique d'états :

  • in_progress avec started_at
  • completed avec la réponse mise en cache
  • failed avec un code d'erreur (optionnel, selon votre politique de réessai)
  • expires_at (pour le nettoyage)

Exemple : deux instances d'application reçoivent la même requête de paiement. L'instance A insère la clé et marque in_progress, puis appelle le provider. L'instance B frappe le chemin de conflit, lit la ligne de dédup, voit in_progress et renvoie une réponse "en cours" rapide (ou attend brièvement puis retente). Quand A termine, elle met à jour la ligne en completed et stocke le corps de réponse pour que les réessais suivants obtiennent exactement la même sortie.

Erreurs fréquentes qui cassent l'idempotence

Générez un backend Go
Générez un backend Go qui conserve la cohérence des effets de bord malgré les timeouts et les réessais.
Créer le backend

La plupart des bugs d'idempotence ne viennent pas du locking compliqué. Ce sont des choix "presque corrects" qui échouent sous réessais, timeouts ou actions parallèles par deux utilisateurs.

Un piège courant est de traiter la clé d'idempotence comme globalement unique. Si vous ne la scopez pas (par utilisateur, compte ou endpoint), deux clients différents peuvent entrer en collision et l'un recevra le résultat de l'autre.

Autre problème : accepter la même clé avec un corps différent. Si le premier appel était pour 10$ et le replay pour 100$, vous ne devez pas renvoyer silencieusement le premier résultat. Stockez un request_hash, comparez au replay et renvoyez une erreur de conflit claire.

Les clients se perdent aussi quand les replays renvoient une forme ou un code différent. Si le premier appel a renvoyé 201 avec un JSON, le replay doit renvoyer le même JSON et le même code. Changer le comportement de replay force les clients à deviner.

Erreurs fréquentes provoquant des doublons :

  • S'appuyer uniquement sur une map en mémoire ou un cache, puis perdre l'état de dédup au redémarrage.
  • Utiliser une clé sans scope (collisions cross-user ou cross-endpoint).
  • Ne pas valider les mismatches de payload pour la même clé.
  • Faire l'effet de bord en premier (charger, insérer, publier) puis écrire la ligne de dédup après.
  • Retourner un nouvel ID généré à chaque réessai au lieu de rejouer le résultat original.

Un cache peut accélérer les lectures, mais la source de vérité doit être durable (généralement PostgreSQL). Sinon, les réessais après un déploiement peuvent créer des doublons.

Prévoyez aussi le nettoyage. Si vous conservez chaque clé indéfiniment, les tables grossissent et les index ralentissent. Fixez une fenêtre de rétention basée sur le comportement réel des réessais, supprimez les vieilles lignes et gardez l'index unique réduit.

Checklist rapide et prochaines étapes

Considérez l'idempotence comme partie intégrante du contrat d'API. Tout endpoint susceptible d'être réessayé par un client, une queue ou une passerelle a besoin d'une règle claire sur ce que signifie "même requête" et sur ce qu'est un "même résultat".

Une checklist avant mise en production :

  • Pour chaque endpoint réessayable, le scope d'idempotence est-il défini (par utilisateur, compte, commande, événement externe) et documenté ?
  • La dédup est-elle appliquée par la base de données (contrainte unique sur la clé d'idempotence et son scope), et pas seulement testée en code ?
  • Au replay, renvoyez-vous le même code et le même corps (ou un sous-ensemble documenté et stable), et pas un objet frais ou un timestamp différent ?
  • Pour les paiements, gérez-vous les résultats inconnus en toute sécurité (timeout après soumission, gateway dit "processing") sans débiter deux fois ?
  • Les logs et métriques indiquent-ils clairement quand une requête a été vue pour la première fois vs rejouée ?

Si un item est un "peut-être", corrigez-le maintenant. La plupart des défaillances apparaissent sous stress : réessais parallèles, réseaux lents et pannes partielles.

Si vous construisez des outils internes ou des apps clients sur AppMaster (appmaster.io), il est utile de concevoir les clés d'idempotence et la table de dédup PostgreSQL tôt. Ainsi, même si la plateforme régénère du code backend Go quand les besoins changent, votre comportement face aux réessais reste cohérent.

FAQ

Pourquoi les réessais créent-ils des prélèvements en double ou des enregistrements dupliqués même si mon API est correcte ?

Les réessais sont normaux : réseaux et clients échouent dans des situations ordinaires. Une requête peut réussir côté serveur mais la réponse n'atteint pas le client, qui réessaie alors et provoque le même traitement deux fois, sauf si le serveur peut reconnaître et rejouer le résultat original.

Que dois-je utiliser comme clé d'idempotence, et qui doit la générer ?

Envoyez la même clé pour chaque réessai d'une même action. Elle doit être générée côté client comme une chaîne aléatoire et difficile à deviner (par exemple un UUID). Ne réutilisez pas la même clé pour une action différente.

Comment dois-je scoper les clés d'idempotence pour éviter les collisions entre utilisateurs ou tenants ?

Scopiez la clé selon votre règle métier : en général par endpoint + identité de l'appelant (utilisateur, compte, tenant ou token API). Cela évite que deux clients différents entrent en collision et reçoivent les résultats l'un de l'autre.

Que doit retourner mon API lorsqu'elle reçoit une requête dupliquée avec la même clé ?

Retournez le même résultat que lors de la première tentative réussie. En pratique, rejouez le même code HTTP et le même corps de réponse, ou au minimum le même ID de ressource et le même état, afin que le client puisse réessayer sans créer d'effet secondaire supplémentaire.

Et si le client réutilise accidentellement la même clé d'idempotence avec un corps de requête différent ?

Rejetez la requête avec une erreur claire de type conflit plutôt que d'essayer de deviner. Stockez et comparez un hash des champs importants de la requête ; si la clé est la même mais que la charge utile diffère, échouez rapidement pour éviter de mélanger deux opérations sous une même clé.

Combien de temps dois-je conserver les clés d'idempotence dans la base de données ?

Conservez les clés assez longtemps pour couvrir les réessais réalistes, puis supprimez-les. Par défaut courant : 24–72 heures pour les paiements, une semaine pour les imports. Pour les webhooks, alignez la durée avec la politique de réessai de l'émetteur afin que les réessais tardifs soient toujours dédupliqués.

Quel est le schéma PostgreSQL le plus simple pour l'idempotence ?

Une table de déduplication dédiée fonctionne bien : la base de données peut imposer une contrainte d'unicité et survivre aux redémarrages. Stockez le scope du propriétaire, la clé, un hash de la requête, un statut et la réponse à rejouer, puis rendez (owner, key) unique pour qu'une seule requête « gagne ».

Comment gérer deux requêtes identiques arrivant en même temps ?

Réclamez la clé à l'intérieur d'une transaction avant d'effectuer l'effet de bord. Si une autre requête arrive en parallèle, elle heurtera la contrainte d'unicité, verra in_progress ou completed et renverra une réponse d'attente ou de rejouement au lieu d'exécuter la logique deux fois.

Comment éviter le double débit quand la passerelle de paiement timeoute ?

Traitez les timeouts comme "inconnus", pas comme des échecs. Enregistrez un état "pending" et, si vous disposez d'un provider_id, utilisez-le comme source de vérité afin que les réessais renvoient le même résultat de paiement au lieu de créer une nouvelle opération.

Comment rendre les imports sûrs pour les réessais sans obliger les utilisateurs à tout recommencer ?

Dédupliquez à deux niveaux : job-level et item-level. Faites en sorte que les réessais renvoient le même ID de job d'import ; imposez une clé naturelle pour les lignes (par exemple un external_id ou (account_id, email)) avec une contrainte unique ou des upserts pour que le retraitement n'ajoute pas de doublons.

Facile à démarrer
Créer quelque chose d'incroyable

Expérimentez avec AppMaster avec un plan gratuit.
Lorsque vous serez prêt, vous pourrez choisir l'abonnement approprié.

Démarrer