Se rendre au contenu

Intégrations Odoo : garantir l'idempotence des APIs

par
Pierre

Dans un écosystème où Odoo dialogue en continu avec Shopify, Stripe, un WMS ou un CRM tiers, le moindre rejeu d'appel peut créer un doublon de commande, une double facturation ou un stock désynchronisé. L'idempotence n'est pas un détail d'implémentation : c'est la fondation d'une intégration fiable. Voici comment la concevoir proprement côté Odoo.

Pourquoi l'idempotence est non négociable

Une intégration synchrone parfaite n'existe pas. Un timeout réseau, un 502 Bad Gateway, un message Kafka rejoué, un retry automatique du connecteur AldenSync ou tout simplement un opérateur qui clique deux fois sur "Synchroniser" : autant de situations où le même payload arrive deux fois sur Odoo. Sans garde-fou, vous obtenez deux sale.order identiques, deux mouvements de stock, ou pire, deux paiements rapprochés sur la même facture.

L'idempotence garantit qu'une opération exécutée plusieurs fois produit exactement le même état final qu'une exécution unique. Concrètement, cela signifie que rejouer la création d'une commande SO12345 doit, au pire, ne rien faire, et au mieux mettre à jour des champs sans dupliquer l'enregistrement. Pour les équipes intégration, c'est aussi un confort opérationnel énorme : on peut réémettre un lot complet sans craindre la corruption des données.

Le pattern de référence : la clé d'idempotence

Le mécanisme le plus robuste consiste à exiger une clé d'idempotence externe sur chaque appel entrant. Cette clé, fournie par le système amont (Shopify, votre middleware, un ESB), identifie de manière stable une intention métier : un identifiant de commande externe, un UUID de webhook, un hash du payload, etc.

Côté Odoo, on matérialise cette clé via un champ dédié sur le modèle concerné, indexé et marqué unique. Par exemple, pour absorber les commandes Shopify sans doublons :

class SaleOrder(models.Model):
    _inherit = "sale.order"

    x_external_ref = fields.Char(
        string="Référence externe",
        index=True,
        copy=False,
    )

    _sql_constraints = [
        (
            "x_external_ref_uniq",
            "unique(x_external_ref)",
            "Une commande avec cette référence externe existe déjà.",
        ),
    ]

Toute tentative d'insertion d'un doublon est alors bloquée par PostgreSQL via une violation de contrainte, beaucoup plus fiable qu'un search applicatif sujet aux courses critiques.

Upsert côté Odoo : la logique applicative

L'unicité SQL protège l'intégrité, mais ne dit pas quoi faire en cas de rejeu. La règle métier doit être explicite : ignorer, mettre à jour partiellement, ou réémettre une réponse identique. Le pattern upsert idempotent couvre ces cas avec une logique compacte :

@api.model
def sync_external_order(self, payload):
    ref = payload["external_id"]
    existing = self.search([("x_external_ref", "=", ref)], limit=1)
    if existing:
        # Rejeu détecté : on retourne la même réponse, sans muter.
        return {"id": existing.id, "status": "already_synced"}

    try:
        order = self.create({
            "x_external_ref": ref,
            "partner_id": self._resolve_partner(payload),
            "order_line": self._build_lines(payload),
        })
    except IntegrityError:
        # Course critique entre deux workers : on relit.
        self.env.cr.rollback()
        order = self.search([("x_external_ref", "=", ref)], limit=1)
        return {"id": order.id, "status": "already_synced"}

    return {"id": order.id, "status": "created"}

Cette double protection — recherche préalable plus rattrapage sur IntegrityError — est essentielle dès que plusieurs workers consomment la même file. Sans elle, deux messages traités en parallèle peuvent franchir le search avant que l'un ait committé.

Webhooks entrants : déduplication au plus tôt

Pour les flux poussés (webhooks Stripe, Shopify, GitHub), il ne faut pas attendre la couche métier pour détecter les doublons. Une table dédiée stocke l'identifiant unique du webhook et son statut :

class WebhookLog(models.Model):
    _name = "x.webhook.log"

    event_id = fields.Char(required=True, index=True)
    source = fields.Char(required=True)
    received_at = fields.Datetime(default=fields.Datetime.now)
    status = fields.Selection([
        ("processed", "Traité"),
        ("duplicate", "Doublon"),
        ("error", "Erreur"),
    ])

    _sql_constraints = [
        ("event_uniq", "unique(source, event_id)", "Webhook déjà reçu"),
    ]

Le contrôleur HTTP enregistre l'événement avant tout traitement. Si l'insertion échoue, on répond immédiatement 200 OK avec un message "duplicate" — le fournisseur arrête ses retries et la file ne se charge pas inutilement.

Idempotence sortante : appels sortants depuis Odoo

Le sujet vaut aussi dans l'autre sens. Quand Odoo pousse une commande vers un WMS, un retry naïf peut créer deux missions de préparation. Trois bonnes pratiques s'imposent : générer côté Odoo un Idempotency-Key stable (UUID5 dérivé de l'identifiant de la commande, par exemple), le transmettre en en-tête HTTP, et persister la corrélation dans un champ x_remote_id dès réception de la réponse. Si la requête timeout, le retry suivant porte la même clé : le WMS reconnaît l'appel et renvoie l'ID déjà émis au lieu de créer un nouveau record.

Cette discipline évite les compensations métier complexes ("rechercher le doublon, l'annuler, recréer") qui empoisonnent les équipes support en production.

Observabilité : sans elle, vous volez à l'aveugle

Une intégration idempotente reste utile seulement si vous savez quand elle déduplique. Trois métriques minimales à instrumenter : le nombre d'événements reçus, le nombre rejoués (statut "duplicate"), et la latence de traitement. Un pic soudain de doublons révèle souvent un dysfonctionnement amont — un middleware qui rejoue, un consommateur qui ne commit pas son offset Kafka — que l'idempotence masque silencieusement. Tracez tout, alertez sur les seuils anormaux, et exposez ces métriques dans Grafana ou dans le module queue.job d'OCA.

Conclusion

Concevoir des intégrations Odoo idempotentes coûte une contrainte SQL, un champ supplémentaire et quelques lignes de logique upsert. En retour, vous gagnez la possibilité de rejouer librement vos flux, de survivre aux pannes réseau et de coucher tranquille pendant les pics de Black Friday. C'est précisément l'approche que nous appliquons par défaut chez AldenSync sur chaque connecteur que nous livrons.

Vous voulez auditer vos intégrations Odoo ou concevoir une nouvelle synchronisation robuste ? Contactez l'équipe AldenSync — nous serons ravis d'échanger sur votre architecture.