La synchronisation bidirectionnelle entre un ERP et un site web semble simple, jusqu'à la première boucle d'écho, le premier doublon, le premier stock négatif. Voici les pièges des connecteurs temps réel : idempotence, résolution de conflits, webhooks, réconciliation, et le code pour les éviter.
Pourquoi le bidirectionnel est un problème de systèmes distribués
Une synchro unidirectionnelle (ERP → site) est triviale : une seule source de vérité, un seul sens de flux. En cas de doute, on réexporte tout depuis l'ERP et on écrase. Le bidirectionnel change de nature : il introduit le problème fondamental des systèmes distribués. Deux systèmes peuvent modifier la même donnée au même moment, sans verrou partagé, sans horloge commune, et chacun croit avoir raison.
Dès qu'on accepte les écritures des deux côtés, on hérite mécaniquement de trois propriétés inconfortables :
- Pas d'ordre global. Les événements de l'ERP et ceux du site n'ont pas d'horloge commune. « Le plus récent » dépend de l'horloge qui l'a daté, et les horloges dérivent.
- Livraison at-least-once. Un réseau qui timeout ne dit pas si le message est passé. Le seul comportement sûr est de rejouer, donc de recevoir des doublons.
- Partitions temporaires. L'ERP redémarre, le site est en maintenance, une file d'attente prend du retard. Le connecteur doit converger après la panne, pas se figer pendant.
J'ai construit ce type de connecteur pour synchroniser le parc d'un loueur de véhicules avec son site de réservation : disponibilité des véhicules, tarifs, réservations, le tout dans les deux sens et en quasi temps réel. Les pièges ci-dessous reviennent dans chaque intégration bidirectionnelle, quel que soit l'ERP.
À retenir : une synchro bidirectionnelle n'est pas « deux synchros unidirectionnelles ». C'est un système distribué à part entière, avec ses conflits, ses doublons et ses ordres d'événements : concevez-le comme tel dès le départ.
Piège n°1 : la boucle d'écho
Le site modifie un stock → l'envoie à l'ERP → l'ERP émet un événement « stock modifié » → renvoyé au site → qui le renvoie à l'ERP… La boucle infinie classique, qui sature les deux systèmes en quelques secondes.
La parade : marquer l'origine de chaque écriture et ignorer ses propres échos. Concrètement, on stocke sur l'entité l'identifiant du système à l'origine de la dernière écriture, et on filtre tout événement entrant qui nous revient.
async function applyRemoteChange(event: SyncEvent) {
// 1. Ignore les événements que NOUS avons générés (anti-écho)
if (event.origin === SYSTEM_ID) return;
// 2. Applique en traçant l'origine pour ne pas réémettre vers la source
await db.update(event.entityId, {
...event.payload,
lastSyncOrigin: event.origin,
});
}
Le marquage d'origine suffit pour un échange à deux systèmes. Au-delà (ERP ↔ site ↔ PIM), il faut propager une trace de propagation (la liste des systèmes déjà traversés) pour éviter qu'un événement ne tourne en triangle. Le principe reste le même : un événement ne doit jamais être réémis vers un système qui l'a déjà vu.
Piège n°2 : l'idempotence et la livraison at-least-once
Un webhook peut être livré deux fois : retry réseau après un timeout, redéploiement de l'émetteur, rejeu manuel après incident. C'est une garantie, pas un accident : la plupart des systèmes d'événements promettent at-least-once, jamais exactly-once. Sans protection, un événement « +1 réservation » appliqué deux fois crée un doublon, et un « stock = stock − 1 » rejoué décrémente deux fois.
La solution : une clé d'idempotence stable par événement (générée par l'émetteur, identique à chaque rejeu), stockée et vérifiée avant traitement. La fenêtre de rétention doit couvrir le pire délai de rejeu réaliste.
async function handleWebhook(event: SyncEvent) {
// SET NX = pose le verrou seulement s'il n'existe pas (atomique)
const firstTime = await redis.set(
`evt:${event.idempotencyKey}`, '1',
'NX', 'EX', 86400, // une seule fois par fenêtre de 24h
);
if (firstTime === null) {
return; // déjà traité → on acquitte en silence (HTTP 200)
}
await processEvent(event);
}
Deux subtilités qui font la différence en production :
- Acquittez toujours un doublon avec un 2xx. Répondre une erreur sur un doublon déjà traité relance la boucle de retry de l'émetteur.
- Préférez l'idempotence métier quand c'est possible. Une opération naturellement idempotente (
stock = 12plutôt questock −= 1) reste correcte même si la déduplication échoue. La clé d'idempotence est un filet, pas une excuse pour écrire des opérations non rejouables.
À retenir : tout handler de webhook doit être idempotent. Partez du principe que chaque message sera livré au moins deux fois, et écrivez des opérations qui restent correctes au rejeu.
Piège n°3 : la résolution de conflits
Deux modifications concurrentes de la même fiche. Qui gagne ? Il faut une stratégie explicite, choisie champ par champ, pas un hasard de timing.
| Stratégie | Principe | Avantage | Limite | Quand l'utiliser |
|---|---|---|---|---|
| Last-write-wins | L'horodatage le plus récent gagne | Simple, sans état | Perte de données silencieuse ; sensible à la dérive d'horloge | Données peu critiques (libellés, notes) |
| Source de vérité par champ | L'ERP gagne sur le prix et le stock, le site sur le contenu marketing | Pas de conflit réel, déterministe | Demande un mapping fin par champ | Le cas le plus courant et le plus robuste |
| Versioning / verrouillage optimiste | Écriture rejetée si la version a changé entre-temps | Aucune perte ; conflit détecté, pas masqué | Nécessite un rejeu applicatif | Données critiques et concurrentes (stock) |
Pour le stock (donnée critique et fortement concurrente), j'utilise le verrouillage optimiste : chaque entité porte un numéro de version, et une écriture échoue si la version a bougé entre la lecture et l'écriture. On ne perd jamais une mise à jour en silence : on détecte le conflit et on rejoue sur la donnée fraîche.
async function updateStock(id: string, qty: number, expectedVersion: number) {
const result = await db.update(
{ id, version: expectedVersion }, // condition (compare-and-swap)
{ stock: qty, version: expectedVersion + 1 }, // mutation
);
if (result.matchedCount === 0) {
// Version périmée : un autre écrivain est passé entre-temps.
// On relit l'état frais et on rejoue la décision métier.
throw new ConflictError(id);
}
}
La règle d'or : le dernier qui écrit ne doit pas forcément gagner. Pour un prix ou un stock, la « bonne » valeur n'est pas la plus récente, c'est celle du système qui en est la source de vérité. La stratégie par champ encode cette responsabilité une fois pour toutes et élimine la majorité des conflits avant qu'ils n'arrivent.
Webhooks ou polling ?
| Critère | Webhooks | Polling |
|---|---|---|
| Latence | Temps réel (push) | Selon l'intervalle |
| Charge | Faible, événementielle | Élevée, requêtes répétées à vide |
| Fiabilité de livraison | À sécuriser (retries, signature) | Garantie par construction |
| Détection des suppressions | Difficile (événement à émettre) | Simple (diff de l'état complet) |
| Ordre des événements | Non garanti | Maîtrisé (snapshot cohérent) |
| Couplage | L'émetteur doit connaître l'URL cible | Le consommateur tire quand il veut |
En pratique, ce n'est pas un choix exclusif : webhooks pour la réactivité, complétés par un polling de réconciliation à intervalle régulier qui rattrape les événements perdus et détecte les divergences. Les webhooks donnent la fraîcheur ; le polling donne la garantie de convergence.
La réconciliation périodique, votre filet de sécurité
Aucun connecteur temps réel n'est fiable à 100 %. Webhook perdu, handler planté avant commit, panne réseau pendant une partition : tôt ou tard, les deux états divergent sans que personne ne le sache. La réconciliation périodique est le mécanisme qui garantit la convergence : à intervalle régulier, on compare les deux états et on répare les écarts selon la stratégie de conflit.
// Filet de sécurité : périodiquement, on compare et on répare.
async function reconcile() {
const [siteState, erpState] = await Promise.all([
fetchSiteSnapshot(),
fetchErpSnapshot(),
]);
for (const diff of computeDiffs(siteState, erpState)) {
await resolveByStrategy(diff); // rejoue selon la stratégie de conflit
await audit.log('reconcile.repair', diff); // tout écart est tracé
}
}
Deux réglages comptent : la fréquence (toutes les heures pour du stock, une fois par nuit pour un catalogue stable) et le périmètre (un diff complet est coûteux ; un diff incrémental sur updatedAt est suffisant la plupart du temps, complété par un diff complet hebdomadaire). Un connecteur sans réconciliation finit toujours par diverger ; la seule question est de savoir combien de temps avant que ça se voie.
Retries et backoff exponentiel
Quand une écriture distante échoue (5xx, timeout, rate limit), rejouer immédiatement ne fait qu'aggraver une panne déjà en cours. On rejoue avec un backoff exponentiel assorti d'un jitter (aléa) pour éviter que tous les clients ne retentent en même temps et ne créent un pic synchronisé.
async function withRetry<T>(fn: () => Promise<T>, max = 5): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= max || !isRetryable(err)) throw err;
const base = Math.min(1000 * 2 ** attempt, 30_000); // plafonné à 30 s
const jitter = Math.random() * base; // anti-effet de troupeau
await sleep(base / 2 + jitter);
}
}
}
Trois garde-fous : ne rejouez que les erreurs transitoires (un 400 ne deviendra jamais un 200), plafonnez le délai et le nombre de tentatives, et envoyez les événements définitivement en échec dans une dead-letter queue pour traitement manuel plutôt que de les perdre.
Gestion des suppressions : le piège des tombstones
Les suppressions sont le talon d'Achille du bidirectionnel. Quand une fiche disparaît, son absence ne déclenche aucun événement « modifié », et un diff naïf peut interpréter « absente d'un côté » comme « à recréer », ressuscitant indéfiniment ce qu'on essaie de supprimer.
La parade est le tombstone (pierre tombale) : au lieu de retirer physiquement la ligne, on la marque supprimée (deletedAt) et on propage cet état comme n'importe quelle autre modification. Le tombstone reste assez longtemps pour que les deux systèmes l'aient vu, puis est purgé.
async function applyDeletion(event: SyncEvent) {
if (event.origin === SYSTEM_ID) return; // anti-écho, même sur suppression
// Soft delete : on garde une trace pour propager et éviter la résurrection
await db.update(event.entityId, {
deletedAt: event.occurredAt,
lastSyncOrigin: event.origin,
});
}
Sans tombstone, la réconciliation et la suppression entrent en guerre : l'un efface, l'autre recrée. C'est l'un des bugs les plus déroutants à diagnostiquer en production.
Ordre des événements
Les webhooks n'arrivent pas dans l'ordre d'émission : retries, parallélisme, files multiples. Si « stock = 0 » arrive avant « stock = 3 » alors qu'il a été émis après, vous publiez une disponibilité fausse. La défense : horodater à la source (occurredAt) et rejeter tout événement plus ancien que la dernière écriture appliquée.
async function applyOrdered(event: SyncEvent) {
const current = await db.get(event.entityId);
// On n'applique que si l'événement est strictement plus récent
if (current && event.occurredAt <= current.updatedAt) {
return; // événement en retard → ignoré, l'état courant est plus frais
}
await db.update(event.entityId, { ...event.payload, updatedAt: event.occurredAt });
}
Mapping de données et observabilité
Deux briques discrètes mais décisives pour la durée de vie d'un connecteur :
- Mapping de données. ERP et site ne parlent jamais le même langage : identifiants, unités, énumérations (
"DISPO"vsavailable), fuseaux horaires, devises. Centralisez la transformation dans une couche de mapping explicite et testée, jamais éparpillée dans les handlers. Validez le format à la frontière (un schéma type Zod) : un payload malformé doit être rejeté avant d'atteindre la base, pas après. - Observabilité et audit. Chaque transition d'état doit être traçable : événement reçu, clé d'idempotence, décision (appliqué / ignoré / conflit), résultat. Sans ce journal, un « pourquoi ce véhicule est-il indisponible ? » devient une enquête de plusieurs heures. Avec, c'est une requête. Exposez aussi des métriques : taux de doublons, conflits par heure, écarts détectés en réconciliation, profondeur de la dead-letter queue.
La checklist d'un connecteur bidirectionnel fiable
- Marquage de l'origine (et trace de propagation au-delà de deux systèmes) pour casser les boucles d'écho
- Clé d'idempotence stable sur chaque événement, avec acquittement 2xx des doublons
- Opérations métier idempotentes quand c'est possible (valeur absolue plutôt que delta)
- Stratégie de conflit explicite par champ (source de vérité, verrouillage optimiste sur le critique)
- Garde d'ordonnancement par horodatage source (
occurredAt) - Suppressions gérées par tombstones, jamais par delete physique immédiat
- Retries avec backoff exponentiel et jitter, plafonnés, dead-letter queue en bout de chaîne
- Réconciliation périodique comme garantie de convergence
- Couche de mapping testée et validation de schéma à la frontière
- Journal d'audit et métriques sur chaque transition d'état
En savoir plus
L'implémentation complète et le contexte métier (parc d'un loueur de véhicules synchronisé avec son site de réservation) sont décrits dans l'étude de cas : Connecteur de synchronisation bidirectionnelle automatisée.
Un ERP, un PIM ou un CRM à connecter à votre site sans tout casser ? Parlons de votre intégration.